From 66a8c257b9e0a7d971ae5dbd70f3806eb4ae8aa9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:56:32 -0700 Subject: [PATCH 001/331] Feishu: lazy-load runtime-heavy channel paths --- extensions/feishu/src/channel.runtime.ts | 6 ++ extensions/feishu/src/channel.ts | 79 ++++++++++++++++++----- extensions/feishu/src/directory.static.ts | 61 +++++++++++++++++ extensions/feishu/src/directory.ts | 65 ++----------------- 4 files changed, 137 insertions(+), 74 deletions(-) create mode 100644 extensions/feishu/src/channel.runtime.ts create mode 100644 extensions/feishu/src/directory.static.ts diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts new file mode 100644 index 00000000000..8068fb350d3 --- /dev/null +++ b/extensions/feishu/src/channel.runtime.ts @@ -0,0 +1,6 @@ +export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js"; +export { feishuOnboardingAdapter } from "./onboarding.js"; +export { feishuOutbound } from "./outbound.js"; +export { probeFeishu } from "./probe.js"; +export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; +export { sendCardFeishu, sendMessageFeishu } from "./send.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 3baa7c916a2..17f3e5cc580 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -22,18 +22,9 @@ import { resolveDefaultFeishuAccountId, } from "./accounts.js"; import { FeishuConfigSchema } from "./config-schema.js"; -import { - listFeishuDirectoryPeers, - listFeishuDirectoryGroups, - listFeishuDirectoryPeersLive, - listFeishuDirectoryGroupsLive, -} from "./directory.js"; -import { feishuOnboardingAdapter } from "./onboarding.js"; -import { feishuOutbound } from "./outbound.js"; +import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; -import { probeFeishu } from "./probe.js"; -import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; -import { sendCardFeishu, sendMessageFeishu } from "./send.js"; +import { getFeishuRuntime } from "./runtime.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; @@ -48,6 +39,47 @@ const meta: ChannelMeta = { order: 70, }; +async function loadFeishuChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const feishuOnboarding = { + channel: "feishu", + getStatus: async (ctx) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx), + configure: async (ctx) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx), + dmPolicy: { + label: "Feishu", + channel: "feishu", + policyKey: "channels.feishu.dmPolicy", + allowFromKey: "channels.feishu.allowFrom", + getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + dmPolicy: policy, + }, + }, + }), + promptAllowFrom: async (cfg, prompter) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom({ + cfg, + prompter, + }), + }, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { ...cfg.channels?.feishu, enabled: false }, + }, + }), +} satisfies ChannelPlugin["onboarding"]; + function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -107,6 +139,7 @@ export const feishuPlugin: ChannelPlugin = { idLabel: "feishuUserId", normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), notifyApproval: async ({ cfg, id }) => { + const { sendMessageFeishu } = await loadFeishuChannelRuntime(); await sendMessageFeishu({ cfg, to: id, @@ -254,6 +287,7 @@ export const feishuPlugin: ChannelPlugin = { typeof ctx.params.replyTo === "string" ? ctx.params.replyTo.trim() || undefined : undefined; + const { sendCardFeishu } = await loadFeishuChannelRuntime(); const result = await sendCardFeishu({ cfg: ctx.cfg, to, @@ -287,6 +321,7 @@ export const feishuPlugin: ChannelPlugin = { if (!emoji) { throw new Error("Emoji is required to remove a Feishu reaction."); } + const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime(); const matches = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -321,6 +356,7 @@ export const feishuPlugin: ChannelPlugin = { "Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.", ); } + const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime(); const reactions = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -341,6 +377,7 @@ export const feishuPlugin: ChannelPlugin = { details: { ok: true, removed }, }; } + const { addReactionFeishu } = await loadFeishuChannelRuntime(); await addReactionFeishu({ cfg: ctx.cfg, messageId, @@ -361,6 +398,7 @@ export const feishuPlugin: ChannelPlugin = { if (!messageId) { throw new Error("Feishu reactions lookup requires messageId."); } + const { listReactionsFeishu } = await loadFeishuChannelRuntime(); const reactions = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -411,7 +449,7 @@ export const feishuPlugin: ChannelPlugin = { return setFeishuNamedAccountEnabled(cfg, accountId, true); }, }, - onboarding: feishuOnboardingAdapter, + onboarding: feishuOnboarding, messaging: { normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined, targetResolver: { @@ -436,28 +474,37 @@ export const feishuPlugin: ChannelPlugin = { accountId: accountId ?? undefined, }), listPeersLive: async ({ cfg, query, limit, accountId }) => - listFeishuDirectoryPeersLive({ + (await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({ cfg, query: query ?? undefined, limit: limit ?? undefined, accountId: accountId ?? undefined, }), listGroupsLive: async ({ cfg, query, limit, accountId }) => - listFeishuDirectoryGroupsLive({ + (await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({ cfg, query: query ?? undefined, limit: limit ?? undefined, accountId: accountId ?? undefined, }), }, - outbound: feishuOutbound, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params), + sendMedia: async (params) => + (await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params), + }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { port: snapshot.port ?? null, }), - probeAccount: async ({ account }) => await probeFeishu(account), + probeAccount: async ({ account }) => + await (await loadFeishuChannelRuntime()).probeFeishu(account), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, diff --git a/extensions/feishu/src/directory.static.ts b/extensions/feishu/src/directory.static.ts new file mode 100644 index 00000000000..b79e4e94f77 --- /dev/null +++ b/extensions/feishu/src/directory.static.ts @@ -0,0 +1,61 @@ +import { + listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listDirectoryUserEntriesFromAllowFromAndMapKeys, +} from "openclaw/plugin-sdk/compat"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { resolveFeishuAccount } from "./accounts.js"; +import { normalizeFeishuTarget } from "./targets.js"; + +export type FeishuDirectoryPeer = { + kind: "user"; + id: string; + name?: string; +}; + +export type FeishuDirectoryGroup = { + kind: "group"; + id: string; + name?: string; +}; + +function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] { + return ids.map((id) => ({ kind: "user", id })); +} + +function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] { + return ids.map((id) => ({ kind: "group", id })); +} + +export async function listFeishuDirectoryPeers(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({ + allowFrom: account.config.allowFrom, + map: account.config.dms, + query: params.query, + limit: params.limit, + normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry, + normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry, + }); + return toFeishuDirectoryPeers(entries.map((entry) => entry.id)); +} + +export async function listFeishuDirectoryGroups(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({ + groups: account.config.groups, + allowFrom: account.config.groupAllowFrom, + query: params.query, + limit: params.limit, + }); + return toFeishuDirectoryGroups(entries.map((entry) => entry.id)); +} diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index 4b5ca584a99..c6366990204 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -1,65 +1,14 @@ -import { - listDirectoryGroupEntriesFromMapKeysAndAllowFrom, - listDirectoryUserEntriesFromAllowFromAndMapKeys, -} from "openclaw/plugin-sdk/compat"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; -import { normalizeFeishuTarget } from "./targets.js"; +import { + listFeishuDirectoryGroups, + listFeishuDirectoryPeers, + type FeishuDirectoryGroup, + type FeishuDirectoryPeer, +} from "./directory.static.js"; -export type FeishuDirectoryPeer = { - kind: "user"; - id: string; - name?: string; -}; - -export type FeishuDirectoryGroup = { - kind: "group"; - id: string; - name?: string; -}; - -function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] { - return ids.map((id) => ({ kind: "user", id })); -} - -function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] { - return ids.map((id) => ({ kind: "group", id })); -} - -export async function listFeishuDirectoryPeers(params: { - cfg: ClawdbotConfig; - query?: string; - limit?: number; - accountId?: string; -}): Promise { - const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); - const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({ - allowFrom: account.config.allowFrom, - map: account.config.dms, - query: params.query, - limit: params.limit, - normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry, - normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry, - }); - return toFeishuDirectoryPeers(entries.map((entry) => entry.id)); -} - -export async function listFeishuDirectoryGroups(params: { - cfg: ClawdbotConfig; - query?: string; - limit?: number; - accountId?: string; -}): Promise { - const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); - const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({ - groups: account.config.groups, - allowFrom: account.config.groupAllowFrom, - query: params.query, - limit: params.limit, - }); - return toFeishuDirectoryGroups(entries.map((entry) => entry.id)); -} +export { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js"; export async function listFeishuDirectoryPeersLive(params: { cfg: ClawdbotConfig; From ae6ee73097e19b9a3cd0eb5ab32b5ac7998dcb45 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:01:04 -0700 Subject: [PATCH 002/331] Google Chat: lazy-load runtime-heavy channel paths --- extensions/googlechat/src/channel.runtime.ts | 2 ++ extensions/googlechat/src/channel.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 extensions/googlechat/src/channel.runtime.ts diff --git a/extensions/googlechat/src/channel.runtime.ts b/extensions/googlechat/src/channel.runtime.ts new file mode 100644 index 00000000000..fdf060f9fd4 --- /dev/null +++ b/extensions/googlechat/src/channel.runtime.ts @@ -0,0 +1,2 @@ +export { probeGoogleChat, sendGoogleChatMessage, uploadGoogleChatAttachment } from "./api.js"; +export { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index ef8e92d8ce2..9ea172091f1 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -34,8 +34,6 @@ import { type ResolvedGoogleChatAccount, } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; -import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js"; -import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; import { getGoogleChatRuntime } from "./runtime.js"; import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js"; import { @@ -47,6 +45,10 @@ import { const meta = getChatChannelMeta("googlechat"); +async function loadGoogleChatChannelRuntime() { + return await import("./channel.runtime.js"); +} + const formatAllowFromEntry = (entry: string) => entry .trim() @@ -145,6 +147,7 @@ export const googlechatPlugin: ChannelPlugin = { const user = normalizeGoogleChatTarget(id) ?? id; const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; const space = await resolveGoogleChatOutboundSpace({ account, target }); + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); await sendGoogleChatMessage({ account, space, @@ -300,6 +303,7 @@ export const googlechatPlugin: ChannelPlugin = { }); const space = await resolveGoogleChatOutboundSpace({ account, target: to }); const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); const result = await sendGoogleChatMessage({ account, space, @@ -353,6 +357,8 @@ export const googlechatPlugin: ChannelPlugin = { maxBytes: effectiveMaxBytes, localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, }); + const { sendGoogleChatMessage, uploadGoogleChatAttachment } = + await loadGoogleChatChannelRuntime(); const upload = await uploadGoogleChatAttachment({ account, space, @@ -421,7 +427,8 @@ export const googlechatPlugin: ChannelPlugin = { webhookPath: snapshot.webhookPath ?? null, webhookUrl: snapshot.webhookUrl ?? null, }), - probeAccount: async ({ account }) => probeGoogleChat(account), + probeAccount: async ({ account }) => + (await loadGoogleChatChannelRuntime()).probeGoogleChat(account), buildAccountSnapshot: ({ account, runtime, probe }) => { const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, @@ -450,6 +457,8 @@ export const googlechatPlugin: ChannelPlugin = { setStatus: ctx.setStatus, }); ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); + const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } = + await loadGoogleChatChannelRuntime(); statusSink({ running: true, lastStartAt: Date.now(), From 59bcac472e29b818820941aece0983c590bd4208 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:05:06 +0000 Subject: [PATCH 003/331] fix: gate setup-only plugin side effects --- extensions/discord/index.ts | 3 ++ .../discord/src/monitor/provider.test.ts | 20 ++++++++- extensions/discord/src/monitor/provider.ts | 22 +++++++--- extensions/feishu/index.ts | 3 ++ extensions/line/index.ts | 3 ++ extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/mattermost/index.test.ts | 43 +++++++++++++++++++ extensions/mattermost/index.ts | 3 ++ extensions/nostr/index.ts | 3 ++ extensions/test-utils/plugin-api.ts | 1 + extensions/tlon/index.ts | 3 ++ extensions/zalouser/index.ts | 3 ++ src/plugins/registry.ts | 4 +- src/plugins/types.ts | 3 ++ 14 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 extensions/mattermost/index.test.ts diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index ad441b09bc1..b08a27f80b5 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); + if (api.registrationMode !== "full") { + return; + } registerDiscordSubagentHooks(api); }, }; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 81f8fa9f5e1..8ded5f982ae 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -46,9 +46,11 @@ const { resolveDiscordAllowlistConfigMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, + shouldLogVerboseMock, voiceRuntimeModuleLoadedMock, } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const shouldLogVerboseMock = vi.fn(() => false); return { clientHandleDeployRequestMock: vi.fn(async () => undefined), clientConstructorOptionsMock: vi.fn(), @@ -110,6 +112,7 @@ const { })), resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeSkillsEnabledMock: vi.fn(() => false), + shouldLogVerboseMock, voiceRuntimeModuleLoadedMock: vi.fn(), }; }); @@ -211,7 +214,7 @@ vi.mock("../../../../src/config/config.js", () => ({ vi.mock("../../../../src/globals.js", () => ({ danger: (v: string) => v, logVerbose: vi.fn(), - shouldLogVerbose: () => false, + shouldLogVerbose: shouldLogVerboseMock, warn: (v: string) => v, })); @@ -435,6 +438,7 @@ describe("monitorDiscordProvider", () => { }); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + shouldLogVerboseMock.mockClear().mockReturnValue(false); voiceRuntimeModuleLoadedMock.mockClear(); }); @@ -842,6 +846,7 @@ describe("monitorDiscordProvider", () => { emitter.emit("debug", "WebSocket connection opened"); return { id: "bot-1", username: "Molty" }; }); + shouldLogVerboseMock.mockReturnValue(true); await monitorDiscordProvider({ config: baseConfig(), @@ -861,4 +866,17 @@ describe("monitorDiscordProvider", () => { ), ).toBe(true); }); + + it("keeps Discord startup chatter quiet by default", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime, + }); + + const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); + expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); + }); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index de174b9d8bf..4f8af71f0d5 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -273,14 +273,18 @@ async function deployDiscordCommands(params: { body === undefined ? undefined : Buffer.byteLength(typeof body === "string" ? body : JSON.stringify(body), "utf8"); - params.runtime.log?.( - `discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`, - ); + if (shouldLogVerbose()) { + params.runtime.log?.( + `discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`, + ); + } try { const result = await originalPut(path, data, query); - params.runtime.log?.( - `discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`, - ); + if (shouldLogVerbose()) { + params.runtime.log?.( + `discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`, + ); + } return result; } catch (err) { params.runtime.error?.( @@ -359,6 +363,9 @@ function logDiscordStartupPhase(params: { gateway?: GatewayPlugin; details?: string; }) { + if (!shouldLogVerbose()) { + return; + } const elapsedMs = Math.max(0, Date.now() - params.startAt); const suffix = [params.details, formatDiscordStartupGatewayState(params.gateway)] .filter((value): value is string => Boolean(value)) @@ -768,6 +775,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const lifecycleGateway = client.getPlugin("gateway"); earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway); onEarlyGatewayDebug = (msg: unknown) => { + if (!shouldLogVerbose()) { + return; + } runtime.log?.( `discord startup [${account.accountId}] gateway-debug ${Math.max(0, Date.now() - startupStartedAt)}ms ${String(msg)}`, ); diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index e01a975615a..ba7ac26922b 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -54,6 +54,9 @@ const plugin = { register(api: OpenClawPluginApi) { setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); + if (api.registrationMode !== "full") { + return; + } registerFeishuSubagentHooks(api); registerFeishuDocTools(api); registerFeishuChatTools(api); diff --git a/extensions/line/index.ts b/extensions/line/index.ts index 961baf1f01b..59b1d97920d 100644 --- a/extensions/line/index.ts +++ b/extensions/line/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setLineRuntime(api.runtime); api.registerChannel({ plugin: linePlugin }); + if (api.registrationMode !== "full") { + return; + } registerLineCardCommand(api); }, }; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 7c62501aa6f..bde3767845c 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -32,6 +32,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi id: "lobster", name: "lobster", source: "test", + registrationMode: "full", config: {}, pluginConfig: {}, // oxlint-disable-next-line typescript/no-explicit-any diff --git a/extensions/mattermost/index.test.ts b/extensions/mattermost/index.test.ts new file mode 100644 index 00000000000..b2ef565c4d2 --- /dev/null +++ b/extensions/mattermost/index.test.ts @@ -0,0 +1,43 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import plugin from "./index.js"; + +function createApi( + registrationMode: OpenClawPluginApi["registrationMode"], + registerHttpRoute = vi.fn(), +): OpenClawPluginApi { + return createTestPluginApi({ + id: "mattermost", + name: "Mattermost", + source: "test", + config: {}, + runtime: {} as OpenClawPluginApi["runtime"], + registrationMode, + registerHttpRoute, + }); +} + +describe("mattermost plugin register", () => { + it("skips slash callback registration in setup-only mode", () => { + const registerHttpRoute = vi.fn(); + + plugin.register(createApi("setup-only", registerHttpRoute)); + + expect(registerHttpRoute).not.toHaveBeenCalled(); + }); + + it("registers slash callback routes in full mode", () => { + const registerHttpRoute = vi.fn(); + + plugin.register(createApi("full", registerHttpRoute)); + + expect(registerHttpRoute).toHaveBeenCalledTimes(1); + expect(registerHttpRoute).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/channels/mattermost/command", + auth: "plugin", + }), + ); + }); +}); diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts index 1dbf616c061..de6f4e1d8a0 100644 --- a/extensions/mattermost/index.ts +++ b/extensions/mattermost/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setMattermostRuntime(api.runtime); api.registerChannel({ plugin: mattermostPlugin }); + if (api.registrationMode !== "full") { + return; + } // Register the HTTP route for slash command callbacks. // The actual command registration with MM happens in the monitor diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index aa8901bd2b9..d8fdb203924 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -14,6 +14,9 @@ const plugin = { register(api: OpenClawPluginApi) { setNostrRuntime(api.runtime); api.registerChannel({ plugin: nostrPlugin }); + if (api.registrationMode !== "full") { + return; + } // Register HTTP handler for profile management const httpHandler = createNostrProfileHttpHandler({ diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index a757344bd31..c2eaeced2e5 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -5,6 +5,7 @@ type TestPluginApiInput = Partial & export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi { return { + registrationMode: "full", logger: { info() {}, warn() {}, error() {}, debug() {} }, registerTool() {}, registerHook() {}, diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 36be4651b1d..2927a9a4b53 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -138,6 +138,9 @@ const plugin = { register(api: OpenClawPluginApi) { setTlonRuntime(api.runtime); api.registerChannel({ plugin: tlonPlugin }); + if (api.registrationMode !== "full") { + return; + } api.logger.debug?.("[tlon] Registering tlon tool"); api.registerTool({ diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index b169292e954..747a7e26531 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setZalouserRuntime(api.runtime); api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock }); + if (api.registrationMode !== "full") { + return; + } api.registerTool({ name: "zalouser", diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 56abbe79bb4..8e04106dc9c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -43,6 +43,7 @@ import type { PluginLogger, PluginOrigin, PluginKind, + PluginRegistrationMode, PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, @@ -186,8 +187,6 @@ type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; -type PluginRegistrationMode = "full" | "setup-only"; - const constrainLegacyPromptInjectionHook = ( handler: PluginHookHandlerMap["before_agent_start"], ): PluginHookHandlerMap["before_agent_start"] => { @@ -734,6 +733,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { description: record.description, source: record.source, rootDir: record.rootDir, + registrationMode, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6b26dfd8fe6..09a706a51ea 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -839,6 +839,8 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); +export type PluginRegistrationMode = "full" | "setup-only"; + export type OpenClawPluginApi = { id: string; name: string; @@ -846,6 +848,7 @@ export type OpenClawPluginApi = { description?: string; source: string; rootDir?: string; + registrationMode: PluginRegistrationMode; config: OpenClawConfig; pluginConfig?: Record; runtime: PluginRuntime; From e8156c8281fa6f0481ddee093222dba9dea81397 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:39:27 +0000 Subject: [PATCH 004/331] feat(web-search): add plugin-backed search providers --- extensions/moonshot/index.ts | 23 +- extensions/web-search-brave/index.ts | 32 + .../web-search-brave/openclaw.plugin.json | 8 + extensions/web-search-brave/package.json | 12 + extensions/web-search-gemini/index.ts | 33 + .../web-search-gemini/openclaw.plugin.json | 8 + extensions/web-search-gemini/package.json | 12 + extensions/web-search-grok/index.ts | 33 + .../web-search-grok/openclaw.plugin.json | 8 + extensions/web-search-grok/package.json | 12 + extensions/web-search-perplexity/index.ts | 33 + .../openclaw.plugin.json | 8 + extensions/web-search-perplexity/package.json | 12 + src/agents/tools/web-search-core.ts | 2235 +++++++++++++++++ src/agents/tools/web-search-plugin-factory.ts | 85 + src/agents/tools/web-search.redirect.test.ts | 44 +- src/agents/tools/web-search.ts | 2228 +--------------- src/commands/onboard-search.test.ts | 70 +- src/commands/onboard-search.ts | 99 +- src/config/config.web-search-provider.test.ts | 34 + src/plugins/loader.ts | 1 + src/plugins/registry.ts | 48 + src/plugins/types.ts | 30 + src/plugins/web-search-providers.test.ts | 137 + src/plugins/web-search-providers.ts | 110 + src/secrets/runtime-web-tools.ts | 168 +- src/secrets/runtime-web-tools.types.ts | 36 + 27 files changed, 3195 insertions(+), 2364 deletions(-) create mode 100644 extensions/web-search-brave/index.ts create mode 100644 extensions/web-search-brave/openclaw.plugin.json create mode 100644 extensions/web-search-brave/package.json create mode 100644 extensions/web-search-gemini/index.ts create mode 100644 extensions/web-search-gemini/openclaw.plugin.json create mode 100644 extensions/web-search-gemini/package.json create mode 100644 extensions/web-search-grok/index.ts create mode 100644 extensions/web-search-grok/openclaw.plugin.json create mode 100644 extensions/web-search-grok/package.json create mode 100644 extensions/web-search-perplexity/index.ts create mode 100644 extensions/web-search-perplexity/openclaw.plugin.json create mode 100644 extensions/web-search-perplexity/package.json create mode 100644 src/agents/tools/web-search-core.ts create mode 100644 src/agents/tools/web-search-plugin-factory.ts create mode 100644 src/plugins/web-search-providers.test.ts create mode 100644 src/plugins/web-search-providers.ts create mode 100644 src/secrets/runtime-web-tools.types.ts diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 59176e42c15..44f77d7b56b 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,9 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, } from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js"; +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; const PROVIDER_ID = "moonshot"; @@ -46,6 +52,21 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 40, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), + }), + ); }, }; diff --git a/extensions/web-search-brave/index.ts b/extensions/web-search-brave/index.ts new file mode 100644 index 00000000000..7345e10f011 --- /dev/null +++ b/extensions/web-search-brave/index.ts @@ -0,0 +1,32 @@ +import { + createPluginBackedWebSearchProvider, + getTopLevelCredentialValue, + setTopLevelCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const braveSearchPlugin = { + id: "web-search-brave", + name: "Web Search Brave Provider", + description: "Bundled Brave provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "brave", + label: "Brave Search", + hint: "Structured results · country/language/time filters", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + docsUrl: "https://docs.openclaw.ai/brave-search", + autoDetectOrder: 10, + getCredentialValue: getTopLevelCredentialValue, + setCredentialValue: setTopLevelCredentialValue, + }), + ); + }, +}; + +export default braveSearchPlugin; diff --git a/extensions/web-search-brave/openclaw.plugin.json b/extensions/web-search-brave/openclaw.plugin.json new file mode 100644 index 00000000000..606091921e9 --- /dev/null +++ b/extensions/web-search-brave/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-brave", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-brave/package.json b/extensions/web-search-brave/package.json new file mode 100644 index 00000000000..c8807445a28 --- /dev/null +++ b/extensions/web-search-brave/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-brave", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Brave web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-gemini/index.ts b/extensions/web-search-gemini/index.ts new file mode 100644 index 00000000000..998fbd69a04 --- /dev/null +++ b/extensions/web-search-gemini/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const geminiSearchPlugin = { + id: "web-search-gemini", + name: "Web Search Gemini Provider", + description: "Bundled Gemini provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 20, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), + }), + ); + }, +}; + +export default geminiSearchPlugin; diff --git a/extensions/web-search-gemini/openclaw.plugin.json b/extensions/web-search-gemini/openclaw.plugin.json new file mode 100644 index 00000000000..a2baa4b274d --- /dev/null +++ b/extensions/web-search-gemini/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-gemini", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-gemini/package.json b/extensions/web-search-gemini/package.json new file mode 100644 index 00000000000..1a595b2b060 --- /dev/null +++ b/extensions/web-search-gemini/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-gemini", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Gemini web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-grok/index.ts b/extensions/web-search-grok/index.ts new file mode 100644 index 00000000000..726879ed43b --- /dev/null +++ b/extensions/web-search-grok/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const grokSearchPlugin = { + id: "web-search-grok", + name: "Web Search Grok Provider", + description: "Bundled Grok provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envVars: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 30, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), + }), + ); + }, +}; + +export default grokSearchPlugin; diff --git a/extensions/web-search-grok/openclaw.plugin.json b/extensions/web-search-grok/openclaw.plugin.json new file mode 100644 index 00000000000..ccc55644521 --- /dev/null +++ b/extensions/web-search-grok/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-grok", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-grok/package.json b/extensions/web-search-grok/package.json new file mode 100644 index 00000000000..9baa872250e --- /dev/null +++ b/extensions/web-search-grok/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-grok", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Grok web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-perplexity/index.ts b/extensions/web-search-perplexity/index.ts new file mode 100644 index 00000000000..83f778aba96 --- /dev/null +++ b/extensions/web-search-perplexity/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const perplexitySearchPlugin = { + id: "web-search-perplexity", + name: "Web Search Perplexity Provider", + description: "Bundled Perplexity provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/country/language/time filters", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + docsUrl: "https://docs.openclaw.ai/perplexity", + autoDetectOrder: 50, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), + }), + ); + }, +}; + +export default perplexitySearchPlugin; diff --git a/extensions/web-search-perplexity/openclaw.plugin.json b/extensions/web-search-perplexity/openclaw.plugin.json new file mode 100644 index 00000000000..fc9907a3dc2 --- /dev/null +++ b/extensions/web-search-perplexity/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-perplexity", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-perplexity/package.json b/extensions/web-search-perplexity/package.json new file mode 100644 index 00000000000..d3724a3b2e3 --- /dev/null +++ b/extensions/web-search-perplexity/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-perplexity", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Perplexity web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/agents/tools/web-search-core.ts b/src/agents/tools/web-search-core.ts new file mode 100644 index 00000000000..48d2d620b49 --- /dev/null +++ b/src/agents/tools/web-search-core.ts @@ -0,0 +1,2235 @@ +import { Type } from "@sinclair/typebox"; +import { formatCliCommand } from "../../cli/command-format.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; +import { logVerbose } from "../../globals.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; +import { wrapWebContent } from "../../security/external-content.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js"; +import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; +import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js"; +import { + CacheEntry, + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + resolveTimeoutSeconds, + writeCache, +} from "./web-shared.js"; + +const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const DEFAULT_SEARCH_COUNT = 5; +const MAX_SEARCH_COUNT = 10; + +const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; +const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; +const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; +const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; +const DEFAULT_GROK_MODEL = "grok-4-1-fast"; +const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; +const KIMI_WEB_SEARCH_TOOL = { + type: "builtin_function", + function: { name: "$web_search" }, +} as const; + +const SEARCH_CACHE_KEY = Symbol.for("openclaw.web-search.cache"); + +function getSharedSearchCache(): Map>> { + const root = globalThis as Record; + const existing = root[SEARCH_CACHE_KEY]; + if (existing instanceof Map) { + return existing as Map>>; + } + const next = new Map>>(); + root[SEARCH_CACHE_KEY] = next; + return next; +} + +const SEARCH_CACHE = getSharedSearchCache(); +const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); +const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; +const BRAVE_SEARCH_LANG_CODES = new Set([ + "ar", + "eu", + "bn", + "bg", + "ca", + "zh-hans", + "zh-hant", + "hr", + "cs", + "da", + "nl", + "en", + "en-gb", + "et", + "fi", + "fr", + "gl", + "de", + "el", + "gu", + "he", + "hi", + "hu", + "is", + "it", + "jp", + "kn", + "ko", + "lv", + "lt", + "ms", + "ml", + "mr", + "nb", + "pl", + "pt-br", + "pt-pt", + "pa", + "ro", + "ru", + "sr", + "sk", + "sl", + "es", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "vi", +]); +const BRAVE_SEARCH_LANG_ALIASES: Record = { + ja: "jp", + zh: "zh-hans", + "zh-cn": "zh-hans", + "zh-hk": "zh-hant", + "zh-sg": "zh-hans", + "zh-tw": "zh-hant", +}; +const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; +const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); + +const FRESHNESS_TO_RECENCY: Record = { + pd: "day", + pw: "week", + pm: "month", + py: "year", +}; +const RECENCY_TO_FRESHNESS: Record = { + day: "pd", + week: "pw", + month: "pm", + year: "py", +}; + +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; + +function isoToPerplexityDate(iso: string): string | undefined { + const match = iso.match(ISO_DATE_PATTERN); + if (!match) { + return undefined; + } + const [, year, month, day] = match; + return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`; +} + +function normalizeToIsoDate(value: string): string | undefined { + const trimmed = value.trim(); + if (ISO_DATE_PATTERN.test(trimmed)) { + return isValidIsoDate(trimmed) ? trimmed : undefined; + } + const match = trimmed.match(PERPLEXITY_DATE_PATTERN); + if (match) { + const [, month, day, year] = match; + const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; + return isValidIsoDate(iso) ? iso : undefined; + } + return undefined; +} + +function createWebSearchSchema(params: { + provider: (typeof SEARCH_PROVIDERS)[number]; + perplexityTransport?: PerplexityTransport; +}) { + const querySchema = { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: MAX_SEARCH_COUNT, + }), + ), + } as const; + + const filterSchema = { + country: Type.Optional( + Type.String({ + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + date_after: Type.Optional( + Type.String({ + description: "Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: "Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + + const perplexityStructuredFilterSchema = { + country: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + date_after: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + + if (params.provider === "brave") { + return Type.Object({ + ...querySchema, + ...filterSchema, + search_lang: Type.Optional( + Type.String({ + description: + "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", + }), + ), + ui_lang: Type.Optional( + Type.String({ + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", + }), + ), + }); + } + + if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + return Type.Object({ + ...querySchema, + freshness: filterSchema.freshness, + }); + } + return Type.Object({ + ...querySchema, + freshness: filterSchema.freshness, + ...perplexityStructuredFilterSchema, + domain_filter: Type.Optional( + Type.Array(Type.String(), { + description: + "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", + }), + ), + max_tokens: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", + minimum: 1, + maximum: 1000000, + }), + ), + max_tokens_per_page: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", + minimum: 1, + }), + ), + }); + } + + // grok, gemini, kimi, etc. + return Type.Object({ + ...querySchema, + ...filterSchema, + }); +} + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +type BraveSearchResult = { + title?: string; + url?: string; + description?: string; + age?: string; +}; + +type BraveSearchResponse = { + web?: { + results?: BraveSearchResult[]; + }; +}; + +type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; +type BraveLlmContextResponse = { + grounding: { generic?: BraveLlmContextResult[] }; + sources?: { url?: string; hostname?: string; date?: string }[]; +}; + +type BraveConfig = { + mode?: string; +}; + +type PerplexityConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; +type PerplexityTransport = "search_api" | "chat_completions"; +type PerplexityBaseUrlHint = "direct" | "openrouter"; + +type GrokConfig = { + apiKey?: string; + model?: string; + inlineCitations?: boolean; +}; + +type KimiConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type GrokSearchResponse = { + output?: Array<{ + type?: string; + role?: string; + text?: string; // present when type === "output_text" (top-level output_text block) + content?: Array<{ + type?: string; + text?: string; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; + }>; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; + }>; + output_text?: string; // deprecated field - kept for backwards compatibility + citations?: string[]; + inline_citations?: Array<{ + start_index: number; + end_index: number; + url: string; + }>; +}; + +type KimiToolCall = { + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +}; + +type KimiMessage = { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: KimiToolCall[]; +}; + +type KimiSearchResponse = { + choices?: Array<{ + finish_reason?: string; + message?: KimiMessage; + }>; + search_results?: Array<{ + title?: string; + url?: string; + content?: string; + }>; +}; + +type PerplexitySearchResponse = { + choices?: Array<{ + message?: { + content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + title?: string; + start_index?: number; + end_index?: number; + }; + }>; + }; + }>; + citations?: string[]; +}; + +type PerplexitySearchApiResult = { + title?: string; + url?: string; + snippet?: string; + date?: string; + last_updated?: string; +}; + +type PerplexitySearchApiResponse = { + results?: PerplexitySearchApiResult[]; + id?: string; +}; + +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const normalizeUrl = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + }; + + const topLevel = (data.citations ?? []) + .map(normalizeUrl) + .filter((url): url is string => Boolean(url)); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); + if (url) { + citations.push(url); + } + } + } + + return [...new Set(citations)]; +} + +function extractGrokContent(data: GrokSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + // xAI Responses API format: find the message output with text content + for (const output of data.output ?? []) { + if (output.type === "message") { + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + const urls = (block.annotations ?? []) + .filter((a) => a.type === "url_citation" && typeof a.url === "string") + .map((a) => a.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } + } + // Some xAI responses place output_text blocks directly in the output array + // without a message wrapper. + if ( + output.type === "output_text" && + "text" in output && + typeof output.text === "string" && + output.text + ) { + const rawAnnotations = + "annotations" in output && Array.isArray(output.annotations) ? output.annotations : []; + const urls = rawAnnotations + .filter( + (a: Record) => a.type === "url_citation" && typeof a.url === "string", + ) + .map((a: Record) => a.url as string); + return { text: output.text, annotationCitations: [...new Set(urls)] }; + } + } + // Fallback: deprecated output_text field + const text = typeof data.output_text === "string" ? data.output_text : undefined; + return { text, annotationCitations: [] }; +} + +type GeminiConfig = { + apiKey?: string; + model?: string; +}; + +type GeminiGroundingResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { + uri?: string; + title?: string; + }; + }>; + searchEntryPoint?: { + renderedContent?: string; + }; + webSearchQueries?: string[]; + }; + }>; + error?: { + code?: number; + message?: string; + status?: string; + }; +}; + +const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; +const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean { + if (typeof params.search?.enabled === "boolean") { + return params.search.enabled; + } + if (params.sandboxed) { + return true; + } + return true; +} + +function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { + const fromConfigRaw = + search && "apiKey" in search + ? normalizeResolvedSecretInputString({ + value: search.apiKey, + path: "tools.web.search.apiKey", + }) + : undefined; + const fromConfig = normalizeSecretInput(fromConfigRaw); + const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); + return fromConfig || fromEnv || undefined; +} + +function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { + if (provider === "brave") { + return { + error: "missing_brave_api_key", + message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "gemini") { + return { + error: "missing_gemini_api_key", + message: + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "grok") { + return { + error: "missing_xai_api_key", + message: + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "kimi") { + return { + error: "missing_kimi_api_key", + message: + "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + return { + error: "missing_perplexity_api_key", + message: + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { + const raw = + search && "provider" in search && typeof search.provider === "string" + ? search.provider.trim().toLowerCase() + : ""; + if (raw === "brave") { + return "brave"; + } + if (raw === "gemini") { + return "gemini"; + } + if (raw === "grok") { + return "grok"; + } + if (raw === "kimi") { + return "kimi"; + } + if (raw === "perplexity") { + return "perplexity"; + } + + // Auto-detect provider from available API keys (alphabetical order) + if (raw === "") { + // Brave + if (resolveSearchApiKey(search)) { + logVerbose( + 'web_search: no provider configured, auto-detected "brave" from available API keys', + ); + return "brave"; + } + // Gemini + const geminiConfig = resolveGeminiConfig(search); + if (resolveGeminiApiKey(geminiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "gemini" from available API keys', + ); + return "gemini"; + } + // Grok + const grokConfig = resolveGrokConfig(search); + if (resolveGrokApiKey(grokConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "grok" from available API keys', + ); + return "grok"; + } + // Kimi + const kimiConfig = resolveKimiConfig(search); + if (resolveKimiApiKey(kimiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "kimi" from available API keys', + ); + return "kimi"; + } + // Perplexity + const perplexityConfig = resolvePerplexityConfig(search); + const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); + if (perplexityKey) { + logVerbose( + 'web_search: no provider configured, auto-detected "perplexity" from available API keys', + ); + return "perplexity"; + } + } + + return "brave"; +} + +function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { + if (!search || typeof search !== "object") { + return {}; + } + const brave = "brave" in search ? search.brave : undefined; + if (!brave || typeof brave !== "object") { + return {}; + } + return brave as BraveConfig; +} + +function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { + return brave.mode === "llm-context" ? "llm-context" : "web"; +} + +function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { + if (!search || typeof search !== "object") { + return {}; + } + const perplexity = "perplexity" in search ? search.perplexity : undefined; + if (!perplexity || typeof perplexity !== "object") { + return {}; + } + return perplexity as PerplexityConfig; +} + +function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { + apiKey?: string; + source: PerplexityApiKeySource; +} { + const fromConfig = normalizeApiKey(perplexity?.apiKey); + if (fromConfig) { + return { apiKey: fromConfig, source: "config" }; + } + + const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); + if (fromEnvPerplexity) { + return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; + } + + const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); + if (fromEnvOpenRouter) { + return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; + } + + return { apiKey: undefined, source: "none" }; +} + +function normalizeApiKey(key: unknown): string { + return normalizeSecretInput(key); +} + +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { + if (!apiKey) { + return undefined; + } + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +function resolvePerplexityBaseUrl( + perplexity?: PerplexityConfig, + authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret + configuredKey?: string, +): string { + const fromConfig = + perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" + ? perplexity.baseUrl.trim() + : ""; + if (fromConfig) { + return fromConfig; + } + if (authSource === "perplexity_env") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (authSource === "openrouter_env") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + if (authSource === "config") { + const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); + if (inferred === "openrouter") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + return PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; +} + +function resolvePerplexityModel(perplexity?: PerplexityConfig): string { + const fromConfig = + perplexity && "model" in perplexity && typeof perplexity.model === "string" + ? perplexity.model.trim() + : ""; + return fromConfig || DEFAULT_PERPLEXITY_MODEL; +} + +function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + +function resolvePerplexityTransport(perplexity?: PerplexityConfig): { + apiKey?: string; + source: PerplexityApiKeySource; + baseUrl: string; + model: string; + transport: PerplexityTransport; +} { + const auth = resolvePerplexityApiKey(perplexity); + const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); + const model = resolvePerplexityModel(perplexity); + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return { + ...auth, + baseUrl, + model, + transport: + hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + }; +} + +function resolvePerplexitySchemaTransportHint( + perplexity?: PerplexityConfig, +): PerplexityTransport | undefined { + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return hasLegacyOverride ? "chat_completions" : undefined; +} + +function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { + if (!search || typeof search !== "object") { + return {}; + } + const grok = "grok" in search ? search.grok : undefined; + if (!grok || typeof grok !== "object") { + return {}; + } + return grok as GrokConfig; +} + +function resolveGrokApiKey(grok?: GrokConfig): string | undefined { + const fromConfig = normalizeApiKey(grok?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); + return fromEnv || undefined; +} + +function resolveGrokModel(grok?: GrokConfig): string { + const fromConfig = + grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; + return fromConfig || DEFAULT_GROK_MODEL; +} + +function resolveGrokInlineCitations(grok?: GrokConfig): boolean { + return grok?.inlineCitations === true; +} + +function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const kimi = "kimi" in search ? search.kimi : undefined; + if (!kimi || typeof kimi !== "object") { + return {}; + } + return kimi as KimiConfig; +} + +function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { + const fromConfig = normalizeApiKey(kimi?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); + if (fromEnvKimi) { + return fromEnvKimi; + } + const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); + return fromEnvMoonshot || undefined; +} + +function resolveKimiModel(kimi?: KimiConfig): string { + const fromConfig = + kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; + return fromConfig || DEFAULT_KIMI_MODEL; +} + +function resolveKimiBaseUrl(kimi?: KimiConfig): string { + const fromConfig = + kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; + return fromConfig || DEFAULT_KIMI_BASE_URL; +} + +function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const gemini = "gemini" in search ? search.gemini : undefined; + if (!gemini || typeof gemini !== "object") { + return {}; + } + return gemini as GeminiConfig; +} + +function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { + const fromConfig = normalizeApiKey(gemini?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); + return fromEnv || undefined; +} + +function resolveGeminiModel(gemini?: GeminiConfig): string { + const fromConfig = + gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : ""; + return fromConfig || DEFAULT_GEMINI_MODEL; +} + +async function withTrustedWebSearchEndpoint( + params: { + url: string; + timeoutSeconds: number; + init: RequestInit; + }, + run: (response: Response) => Promise, +): Promise { + return withTrustedWebToolsEndpoint( + { + url: params.url, + init: params.init, + timeoutSeconds: params.timeoutSeconds, + }, + async ({ response }) => run(response), + ); +} + +async function runGeminiSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { + const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + }, + body: JSON.stringify({ + contents: [ + { + parts: [{ text: params.query }], + }, + ], + tools: [{ google_search: {} }], + }), + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + // Strip API key from any error detail to prevent accidental key leakage in logs + const safeDetail = (detailResult.text || res.statusText).replace( + /key=[^&\s]+/gi, + "key=***", + ); + throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); + } + + let data: GeminiGroundingResponse; + try { + data = (await res.json()) as GeminiGroundingResponse; + } catch (err) { + const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err }); + } + + if (data.error) { + const rawMsg = data.error.message || data.error.status || "unknown"; + const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`); + } + + const candidate = data.candidates?.[0]; + const content = + candidate?.content?.parts + ?.map((p) => p.text) + .filter(Boolean) + .join("\n") ?? "No response"; + + const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? []; + const rawCitations = groundingChunks + .filter((chunk) => chunk.web?.uri) + .map((chunk) => ({ + url: chunk.web!.uri!, + title: chunk.web?.title || undefined, + })); + + // Resolve Google grounding redirect URLs to direct URLs with concurrency cap. + // Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe. + const MAX_CONCURRENT_REDIRECTS = 10; + const citations: Array<{ url: string; title?: string }> = []; + for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) { + const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS); + const resolved = await Promise.all( + batch.map(async (citation) => { + const resolvedUrl = await resolveCitationRedirectUrl(citation.url); + return { ...citation, url: resolvedUrl }; + }), + ); + citations.push(...resolved); + } + + return { content, citations }; + }, + ); +} + +function resolveSearchCount(value: unknown, fallback: number): number { + const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; + const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); + return clamped; +} + +function normalizeBraveSearchLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); + if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { + return undefined; + } + return canonical; +} + +function normalizeBraveUiLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(BRAVE_UI_LANG_LOCALE); + if (!match) { + return undefined; + } + const [, language, region] = match; + return `${language.toLowerCase()}-${region.toUpperCase()}`; +} + +function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { + search_lang?: string; + ui_lang?: string; + invalidField?: "search_lang" | "ui_lang"; +} { + const rawSearchLang = params.search_lang?.trim() || undefined; + const rawUiLang = params.ui_lang?.trim() || undefined; + let searchLangCandidate = rawSearchLang; + let uiLangCandidate = rawUiLang; + + // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. + if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { + searchLangCandidate = rawUiLang; + uiLangCandidate = rawSearchLang; + } + + const search_lang = normalizeBraveSearchLang(searchLangCandidate); + if (searchLangCandidate && !search_lang) { + return { invalidField: "search_lang" }; + } + + const ui_lang = normalizeBraveUiLang(uiLangCandidate); + if (uiLangCandidate && !ui_lang) { + return { invalidField: "ui_lang" }; + } + + return { search_lang, ui_lang }; +} + +/** + * Normalizes freshness shortcut to the provider's expected format. + * Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year). + * For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD). + */ +function normalizeFreshness( + value: string | undefined, + provider: (typeof SEARCH_PROVIDERS)[number], +): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const lower = trimmed.toLowerCase(); + + if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) { + return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower]; + } + + if (PERPLEXITY_RECENCY_VALUES.has(lower)) { + return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower]; + } + + // Brave date range support + if (provider === "brave") { + const match = trimmed.match(BRAVE_FRESHNESS_RANGE); + if (match) { + const [, start, end] = match; + if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { + return `${start}to${end}`; + } + } + } + + return undefined; +} + +function isValidIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10)); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return false; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day + ); +} + +function resolveSiteName(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`); +} + +async function runPerplexitySearchApi(params: { + query: string; + apiKey: string; + count: number; + timeoutSeconds: number; + country?: string; + searchDomainFilter?: string[]; + searchRecencyFilter?: string; + searchLanguageFilter?: string[]; + searchAfterDate?: string; + searchBeforeDate?: string; + maxTokens?: number; + maxTokensPerPage?: number; +}): Promise< + Array<{ title: string; url: string; description: string; published?: string; siteName?: string }> +> { + const body: Record = { + query: params.query, + max_results: params.count, + }; + + if (params.country) { + body.country = params.country; + } + if (params.searchDomainFilter && params.searchDomainFilter.length > 0) { + body.search_domain_filter = params.searchDomainFilter; + } + if (params.searchRecencyFilter) { + body.search_recency_filter = params.searchRecencyFilter; + } + if (params.searchLanguageFilter && params.searchLanguageFilter.length > 0) { + body.search_language_filter = params.searchLanguageFilter; + } + if (params.searchAfterDate) { + body.search_after_date = params.searchAfterDate; + } + if (params.searchBeforeDate) { + body.search_before_date = params.searchBeforeDate; + } + if (params.maxTokens !== undefined) { + body.max_tokens = params.maxTokens; + } + if (params.maxTokensPerPage !== undefined) { + body.max_tokens_per_page = params.maxTokensPerPage; + } + + return withTrustedWebSearchEndpoint( + { + url: PERPLEXITY_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity Search"); + } + + const data = (await res.json()) as PerplexitySearchApiResponse; + const results = Array.isArray(data.results) ? data.results : []; + + return results.map((entry) => { + const title = entry.title ?? ""; + const url = entry.url ?? ""; + const snippet = entry.snippet ?? ""; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: snippet ? wrapWebContent(snippet, "web_search") : "", + published: entry.date ?? undefined, + siteName: resolveSiteName(url) || undefined, + }; + }); + }, + ); +} + +async function runPerplexitySearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; + freshness?: string; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const model = resolvePerplexityRequestModel(baseUrl, params.model); + + const body: Record = { + model, + messages: [ + { + role: "user", + content: params.query, + }, + ], + }; + + if (params.freshness) { + body.search_recency_filter = params.freshness; + } + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity"); + } + + const data = (await res.json()) as PerplexitySearchResponse; + const content = data.choices?.[0]?.message?.content ?? "No response"; + // Prefer top-level citations; fall back to OpenRouter-style message annotations. + const citations = extractPerplexityCitations(data); + + return { content, citations }; + }, + ); +} + +async function runGrokSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; + inlineCitations: boolean; +}): Promise<{ + content: string; + citations: string[]; + inlineCitations?: GrokSearchResponse["inline_citations"]; +}> { + const body: Record = { + model: params.model, + input: [ + { + role: "user", + content: params.query, + }, + ], + tools: [{ type: "web_search" }], + }; + + // Note: xAI's /v1/responses endpoint does not support the `include` + // parameter (returns 400 "Argument not supported: include"). Inline + // citations are returned automatically when available — we just parse + // them from the response without requesting them explicitly (#12910). + + return withTrustedWebSearchEndpoint( + { + url: XAI_API_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "xAI"); + } + + const data = (await res.json()) as GrokSearchResponse; + const { text: extractedText, annotationCitations } = extractGrokContent(data); + const content = extractedText ?? "No response"; + // Prefer top-level citations; fall back to annotation-derived ones + const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations; + const inlineCitations = data.inline_citations; + + return { content, citations, inlineCitations }; + }, + ); +} + +function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { + const content = message?.content?.trim(); + if (content) { + return content; + } + const reasoning = message?.reasoning_content?.trim(); + return reasoning || undefined; +} + +function extractKimiCitations(data: KimiSearchResponse): string[] { + const citations = (data.search_results ?? []) + .map((entry) => entry.url?.trim()) + .filter((url): url is string => Boolean(url)); + + for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { + const rawArguments = toolCall.function?.arguments; + if (!rawArguments) { + continue; + } + try { + const parsed = JSON.parse(rawArguments) as { + search_results?: Array<{ url?: string }>; + url?: string; + }; + if (typeof parsed.url === "string" && parsed.url.trim()) { + citations.push(parsed.url.trim()); + } + for (const result of parsed.search_results ?? []) { + if (typeof result.url === "string" && result.url.trim()) { + citations.push(result.url.trim()); + } + } + } catch { + // ignore malformed tool arguments + } + } + + return [...new Set(citations)]; +} + +function buildKimiToolResultContent(data: KimiSearchResponse): string { + return JSON.stringify({ + search_results: (data.search_results ?? []).map((entry) => ({ + title: entry.title ?? "", + url: entry.url ?? "", + content: entry.content ?? "", + })), + }); +} + +async function runKimiSearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const messages: Array> = [ + { + role: "user", + content: params.query, + }, + ]; + const collectedCitations = new Set(); + const MAX_ROUNDS = 3; + + for (let round = 0; round < MAX_ROUNDS; round += 1) { + const nextResult = await withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + messages, + tools: [KIMI_WEB_SEARCH_TOOL], + }), + }, + }, + async ( + res, + ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Kimi"); + } + + const data = (await res.json()) as KimiSearchResponse; + for (const citation of extractKimiCitations(data)) { + collectedCitations.add(citation); + } + const choice = data.choices?.[0]; + const message = choice?.message; + const text = extractKimiMessageText(message); + const toolCalls = message?.tool_calls ?? []; + + if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + messages.push({ + role: "assistant", + content: message?.content ?? "", + ...(message?.reasoning_content + ? { + reasoning_content: message.reasoning_content, + } + : {}), + tool_calls: toolCalls, + }); + + const toolContent = buildKimiToolResultContent(data); + let pushedToolResult = false; + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id?.trim(); + if (!toolCallId) { + continue; + } + pushedToolResult = true; + messages.push({ + role: "tool", + tool_call_id: toolCallId, + content: toolContent, + }); + } + + if (!pushedToolResult) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + return { done: false }; + }, + ); + + if (nextResult.done) { + return { content: nextResult.content, citations: nextResult.citations }; + } + } + + return { + content: "Search completed but no final answer was produced.", + citations: [...collectedCitations], + }; +} + +function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + +async function runBraveLlmContextSearch(params: { + query: string; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + freshness?: string; +}): Promise<{ + results: Array<{ + url: string; + title: string; + snippets: string[]; + siteName?: string; + }>; + sources?: BraveLlmContextResponse["sources"]; +}> { + const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); + url.searchParams.set("q", params.query); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveLlmContextResponse; + const mapped = mapBraveLlmContextResults(data); + + return { results: mapped, sources: data.sources }; + }, + ); +} + +async function runWebSearch(params: { + query: string; + count: number; + apiKey: string; + timeoutSeconds: number; + cacheTtlMs: number; + provider: (typeof SEARCH_PROVIDERS)[number]; + country?: string; + language?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + dateAfter?: string; + dateBefore?: string; + searchDomainFilter?: string[]; + maxTokens?: number; + maxTokensPerPage?: number; + perplexityBaseUrl?: string; + perplexityModel?: string; + perplexityTransport?: PerplexityTransport; + grokModel?: string; + grokInlineCitations?: boolean; + geminiModel?: string; + kimiBaseUrl?: string; + kimiModel?: string; + braveMode?: "web" | "llm-context"; +}): Promise> { + const effectiveBraveMode = params.braveMode ?? "web"; + const providerSpecificKey = + params.provider === "perplexity" + ? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` + : params.provider === "grok" + ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` + : params.provider === "gemini" + ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) + : params.provider === "kimi" + ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` + : ""; + const cacheKey = normalizeCacheKey( + params.provider === "brave" && effectiveBraveMode === "llm-context" + ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` + : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const start = Date.now(); + + if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + const { content, citations } = await runPerplexitySearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + timeoutSeconds: params.timeoutSeconds, + freshness: params.freshness, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content, "web_search"), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + const results = await runPerplexitySearchApi({ + query: params.query, + apiKey: params.apiKey, + count: params.count, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + searchDomainFilter: params.searchDomainFilter, + searchRecencyFilter: params.freshness, + searchLanguageFilter: params.language ? [params.language] : undefined, + searchAfterDate: params.dateAfter ? isoToPerplexityDate(params.dateAfter) : undefined, + searchBeforeDate: params.dateBefore ? isoToPerplexityDate(params.dateBefore) : undefined, + maxTokens: params.maxTokens, + maxTokensPerPage: params.maxTokensPerPage, + }); + + const payload = { + query: params.query, + provider: params.provider, + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "grok") { + const { content, citations, inlineCitations } = await runGrokSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.grokInlineCitations ?? false, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content), + citations, + inlineCitations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "kimi") { + const { content, citations } = await runKimiSearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "gemini") { + const geminiResult = await runGeminiSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + tookMs: Date.now() - start, // Includes redirect URL resolution time + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(geminiResult.content), + citations: geminiResult.citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider !== "brave") { + throw new Error("Unsupported web search provider."); + } + + if (effectiveBraveMode === "llm-context") { + const { results: llmResults, sources } = await runBraveLlmContextSearch({ + query: params.query, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + search_lang: params.search_lang, + freshness: params.freshness, + }); + + const mapped = llmResults.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), + siteName: entry.siteName, + })); + + const payload = { + query: params.query, + provider: params.provider, + mode: "llm-context" as const, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + sources, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + const url = new URL(BRAVE_SEARCH_ENDPOINT); + url.searchParams.set("q", params.query); + url.searchParams.set("count", String(params.count)); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang || params.language) { + url.searchParams.set("search_lang", (params.search_lang || params.language)!); + } + if (params.ui_lang) { + url.searchParams.set("ui_lang", params.ui_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } else if (params.dateAfter && params.dateBefore) { + url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); + } else if (params.dateAfter) { + url.searchParams.set( + "freshness", + `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, + ); + } else if (params.dateBefore) { + url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); + } + + const mapped = await withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveSearchResponse; + const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; + return results.map((entry) => { + const description = entry.description ?? ""; + const title = entry.title ?? ""; + const url = entry.url ?? ""; + const rawSiteName = resolveSiteName(url); + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, // Keep raw for tool chaining + description: description ? wrapWebContent(description, "web_search") : "", + published: entry.age || undefined, + siteName: rawSiteName || undefined, + }; + }); + }, + ); + + const payload = { + query: params.query, + provider: params.provider, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; +} + +export function createWebSearchTool(options?: { + config?: OpenClawConfig; + sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; +}): AnyAgentTool | null { + const search = resolveSearchConfig(options?.config); + if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { + return null; + } + + const provider = + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveSearchProvider(search); + const perplexityConfig = resolvePerplexityConfig(search); + const perplexitySchemaTransportHint = + options?.runtimeWebSearch?.perplexityTransport ?? + resolvePerplexitySchemaTransportHint(perplexityConfig); + const grokConfig = resolveGrokConfig(search); + const geminiConfig = resolveGeminiConfig(search); + const kimiConfig = resolveKimiConfig(search); + const braveConfig = resolveBraveConfig(search); + const braveMode = resolveBraveMode(braveConfig); + + const description = + provider === "perplexity" + ? perplexitySchemaTransportHint === "chat_completions" + ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." + : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." + : provider === "grok" + ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." + : provider === "kimi" + ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." + : provider === "gemini" + ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." + : braveMode === "llm-context" + ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + + return { + label: "Web Search", + name: "web_search", + description, + parameters: createWebSearchSchema({ + provider, + perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, + }), + execute: async (_toolCallId, args) => { + // Resolve Perplexity auth/transport lazily at execution time so unrelated providers + // do not touch Perplexity-only credential surfaces during tool construction. + const perplexityRuntime = + provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; + const apiKey = + provider === "perplexity" + ? perplexityRuntime?.apiKey + : provider === "grok" + ? resolveGrokApiKey(grokConfig) + : provider === "kimi" + ? resolveKimiApiKey(kimiConfig) + : provider === "gemini" + ? resolveGeminiApiKey(geminiConfig) + : resolveSearchApiKey(search); + + if (!apiKey) { + return jsonResult(missingSearchKeyPayload(provider)); + } + + const supportsStructuredPerplexityFilters = + provider === "perplexity" && perplexityRuntime?.transport === "search_api"; + const params = args as Record; + const query = readStringParam(params, "query", { required: true }); + const count = + readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; + const country = readStringParam(params, "country"); + if ( + country && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_country", + message: + provider === "perplexity" + ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const language = readStringParam(params, "language"); + if ( + language && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_language", + message: + provider === "perplexity" + ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (language && provider === "perplexity" && !/^[a-z]{2}$/i.test(language)) { + return jsonResult({ + error: "invalid_language", + message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const search_lang = readStringParam(params, "search_lang"); + const ui_lang = readStringParam(params, "ui_lang"); + // For Brave, accept both `language` (unified) and `search_lang` + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ search_lang: search_lang || language, ui_lang }) + : { search_lang: language, ui_lang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + return jsonResult({ + error: "invalid_search_lang", + message: + "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return jsonResult({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; + const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; + if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_ui_lang", + message: + "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const rawFreshness = readStringParam(params, "freshness"); + if (rawFreshness && provider !== "brave" && provider !== "perplexity") { + return jsonResult({ + error: "unsupported_freshness", + message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (rawFreshness && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_freshness", + message: + "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; + if (rawFreshness && !freshness) { + return jsonResult({ + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const rawDateAfter = readStringParam(params, "date_after"); + const rawDateBefore = readStringParam(params, "date_before"); + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return jsonResult({ + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + (rawDateAfter || rawDateBefore) && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_date_filter", + message: + provider === "perplexity" + ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." + : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; + if (rawDateAfter && !dateAfter) { + return jsonResult({ + error: "invalid_date", + message: "date_after must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; + if (rawDateBefore && !dateBefore) { + return jsonResult({ + error: "invalid_date", + message: "date_before must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (dateAfter && dateBefore && dateAfter > dateBefore) { + return jsonResult({ + error: "invalid_date_range", + message: "date_after must be before date_before.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const domainFilter = readStringArrayParam(params, "domain_filter"); + if ( + domainFilter && + domainFilter.length > 0 && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_domain_filter", + message: + provider === "perplexity" + ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + + if (domainFilter && domainFilter.length > 0) { + const hasDenylist = domainFilter.some((d) => d.startsWith("-")); + const hasAllowlist = domainFilter.some((d) => !d.startsWith("-")); + if (hasDenylist && hasAllowlist) { + return jsonResult({ + error: "invalid_domain_filter", + message: + "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (domainFilter.length > 20) { + return jsonResult({ + error: "invalid_domain_filter", + message: "domain_filter supports a maximum of 20 domains.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + } + + const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); + const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); + if ( + provider === "perplexity" && + perplexityRuntime?.transport === "chat_completions" && + (maxTokens !== undefined || maxTokensPerPage !== undefined) + ) { + return jsonResult({ + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + + const result = await runWebSearch({ + query, + count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + apiKey, + timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + provider, + country, + language, + search_lang: resolvedSearchLang, + ui_lang: resolvedUiLang, + freshness, + dateAfter, + dateBefore, + searchDomainFilter: domainFilter, + maxTokens: maxTokens ?? undefined, + maxTokensPerPage: maxTokensPerPage ?? undefined, + perplexityBaseUrl: perplexityRuntime?.baseUrl, + perplexityModel: perplexityRuntime?.model, + perplexityTransport: perplexityRuntime?.transport, + grokModel: resolveGrokModel(grokConfig), + grokInlineCitations: resolveGrokInlineCitations(grokConfig), + geminiModel: resolveGeminiModel(geminiConfig), + kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), + kimiModel: resolveKimiModel(kimiConfig), + braveMode, + }); + return jsonResult(result); + }, + }; +} + +export const __testing = { + resolveSearchProvider, + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, + normalizeBraveLanguageParams, + normalizeFreshness, + normalizeToIsoDate, + isoToPerplexityDate, + SEARCH_CACHE, + FRESHNESS_TO_RECENCY, + RECENCY_TO_FRESHNESS, + resolveGrokApiKey, + resolveGrokModel, + resolveGrokInlineCitations, + extractGrokContent, + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, + resolveRedirectUrl: resolveCitationRedirectUrl, + resolveBraveMode, + mapBraveLlmContextResults, +} as const; diff --git a/src/agents/tools/web-search-plugin-factory.ts b/src/agents/tools/web-search-plugin-factory.ts new file mode 100644 index 00000000000..8022b2e354d --- /dev/null +++ b/src/agents/tools/web-search-plugin-factory.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { WebSearchProviderPlugin } from "../../plugins/types.js"; +import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js"; + +function cloneWithDescriptors(value: T | undefined): T { + const next = Object.create(Object.getPrototypeOf(value ?? {})) as T; + if (value) { + Object.defineProperties(next, Object.getOwnPropertyDescriptors(value)); + } + return next; +} + +function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig { + const next = cloneWithDescriptors(config ?? {}); + const tools = cloneWithDescriptors(next.tools ?? {}); + const web = cloneWithDescriptors(tools.web ?? {}); + const search = cloneWithDescriptors(web.search ?? {}); + + search.provider = provider; + web.search = search; + tools.web = web; + next.tools = tools; + + return next; +} + +export function createPluginBackedWebSearchProvider( + provider: Omit, +): WebSearchProviderPlugin { + return { + ...provider, + createTool: (ctx) => { + const tool = createLegacyWebSearchTool({ + config: withForcedProvider(ctx.config, provider.id), + runtimeWebSearch: ctx.runtimeMetadata, + }); + if (!tool) { + return null; + } + return { + description: tool.description, + parameters: tool.parameters as Record, + execute: async (args) => { + const result = await tool.execute(`web-search:${provider.id}`, args); + return (result.details ?? {}) as Record; + }, + }; + }, + }; +} + +export function getTopLevelCredentialValue(searchConfig?: Record): unknown { + return searchConfig?.apiKey; +} + +export function setTopLevelCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + searchConfigTarget.apiKey = value; +} + +export function getScopedCredentialValue( + searchConfig: Record | undefined, + key: string, +): unknown { + const scoped = searchConfig?.[key]; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +export function setScopedCredentialValue( + searchConfigTarget: Record, + key: string, + value: unknown, +): void { + const scoped = searchConfigTarget[key]; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget[key] = { apiKey: value }; + return; + } + (scoped as Record).apiKey = value; +} diff --git a/src/agents/tools/web-search.redirect.test.ts b/src/agents/tools/web-search.redirect.test.ts index cac014d7e9a..d00c6a31995 100644 --- a/src/agents/tools/web-search.redirect.test.ts +++ b/src/agents/tools/web-search.redirect.test.ts @@ -1,48 +1,48 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ - fetchWithSsrFGuardMock: vi.fn(), +const { withStrictWebToolsEndpointMock } = vi.hoisted(() => ({ + withStrictWebToolsEndpointMock: vi.fn(), })); -vi.mock("../../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, +vi.mock("./web-guarded-fetch.js", () => ({ + withStrictWebToolsEndpoint: withStrictWebToolsEndpointMock, })); -import { __testing } from "./web-search.js"; - describe("web_search redirect resolution hardening", () => { - const { resolveRedirectUrl } = __testing; + async function resolveRedirectUrl() { + const module = await import("./web-search-citation-redirect.js"); + return module.resolveCitationRedirectUrl; + } beforeEach(() => { - fetchWithSsrFGuardMock.mockReset(); + vi.resetModules(); + withStrictWebToolsEndpointMock.mockReset(); }); it("resolves redirects via SSRF-guarded HEAD requests", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(null, { status: 200 }), - finalUrl: "https://example.com/final", - release, + const resolve = await resolveRedirectUrl(); + withStrictWebToolsEndpointMock.mockImplementation(async (_params, run) => { + return await run({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com/final", + }); }); - const resolved = await resolveRedirectUrl("https://example.com/start"); + const resolved = await resolve("https://example.com/start"); expect(resolved).toBe("https://example.com/final"); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect(withStrictWebToolsEndpointMock).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com/start", timeoutMs: 5000, init: { method: "HEAD" }, }), + expect.any(Function), ); - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.proxy).toBeUndefined(); - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy).toBeUndefined(); - expect(release).toHaveBeenCalledTimes(1); }); it("falls back to the original URL when guarded resolution fails", async () => { - fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked")); - await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe( - "https://example.com/start", - ); + const resolve = await resolveRedirectUrl(); + withStrictWebToolsEndpointMock.mockRejectedValue(new Error("blocked")); + await expect(resolve("https://example.com/start")).resolves.toBe("https://example.com/start"); }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 6e9518f1ede..869da014d45 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,286 +1,12 @@ -import { Type } from "@sinclair/typebox"; -import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { logVerbose } from "../../globals.js"; -import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; -import { wrapWebContent } from "../../security/external-content.js"; +import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js"; -import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; -import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js"; -import { - CacheEntry, - DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, - normalizeCacheKey, - readCache, - readResponseText, - resolveCacheTtlMs, - resolveTimeoutSeconds, - writeCache, -} from "./web-shared.js"; - -const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; -const DEFAULT_SEARCH_COUNT = 5; -const MAX_SEARCH_COUNT = 10; - -const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; -const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; -const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; -const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; - -const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; -const DEFAULT_GROK_MODEL = "grok-4-1-fast"; -const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; -const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; -const KIMI_WEB_SEARCH_TOOL = { - type: "builtin_function", - function: { name: "$web_search" }, -} as const; - -const SEARCH_CACHE = new Map>>(); -const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); -const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; -const BRAVE_SEARCH_LANG_CODES = new Set([ - "ar", - "eu", - "bn", - "bg", - "ca", - "zh-hans", - "zh-hant", - "hr", - "cs", - "da", - "nl", - "en", - "en-gb", - "et", - "fi", - "fr", - "gl", - "de", - "el", - "gu", - "he", - "hi", - "hu", - "is", - "it", - "jp", - "kn", - "ko", - "lv", - "lt", - "ms", - "ml", - "mr", - "nb", - "pl", - "pt-br", - "pt-pt", - "pa", - "ro", - "ru", - "sr", - "sk", - "sl", - "es", - "sv", - "ta", - "te", - "th", - "tr", - "uk", - "vi", -]); -const BRAVE_SEARCH_LANG_ALIASES: Record = { - ja: "jp", - zh: "zh-hans", - "zh-cn": "zh-hans", - "zh-hk": "zh-hant", - "zh-sg": "zh-hans", - "zh-tw": "zh-hant", -}; -const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; -const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); - -const FRESHNESS_TO_RECENCY: Record = { - pd: "day", - pw: "week", - pm: "month", - py: "year", -}; -const RECENCY_TO_FRESHNESS: Record = { - day: "pd", - week: "pw", - month: "pm", - year: "py", -}; - -const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; -const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; - -function isoToPerplexityDate(iso: string): string | undefined { - const match = iso.match(ISO_DATE_PATTERN); - if (!match) { - return undefined; - } - const [, year, month, day] = match; - return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`; -} - -function normalizeToIsoDate(value: string): string | undefined { - const trimmed = value.trim(); - if (ISO_DATE_PATTERN.test(trimmed)) { - return isValidIsoDate(trimmed) ? trimmed : undefined; - } - const match = trimmed.match(PERPLEXITY_DATE_PATTERN); - if (match) { - const [, month, day, year] = match; - const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; - return isValidIsoDate(iso) ? iso : undefined; - } - return undefined; -} - -function createWebSearchSchema(params: { - provider: (typeof SEARCH_PROVIDERS)[number]; - perplexityTransport?: PerplexityTransport; -}) { - const querySchema = { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - } as const; - - const filterSchema = { - country: Type.Optional( - Type.String({ - description: - "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - freshness: Type.Optional( - Type.String({ - description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", - }), - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - - const perplexityStructuredFilterSchema = { - country: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - date_after: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - - if (params.provider === "brave") { - return Type.Object({ - ...querySchema, - ...filterSchema, - search_lang: Type.Optional( - Type.String({ - description: - "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", - }), - ), - ui_lang: Type.Optional( - Type.String({ - description: - "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", - }), - ), - }); - } - - if (params.provider === "perplexity") { - if (params.perplexityTransport === "chat_completions") { - return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, - }); - } - return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, - ...perplexityStructuredFilterSchema, - domain_filter: Type.Optional( - Type.Array(Type.String(), { - description: - "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", - }), - ), - max_tokens: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", - minimum: 1, - maximum: 1000000, - }), - ), - max_tokens_per_page: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", - minimum: 1, - }), - ), - }); - } - - // grok, gemini, kimi, etc. - return Type.Object({ - ...querySchema, - ...filterSchema, - }); -} +import { jsonResult } from "./common.js"; +import { __testing as coreTesting } from "./web-search-core.js"; type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } @@ -288,248 +14,6 @@ type WebSearchConfig = NonNullable["web"] extends infer : undefined : undefined; -type BraveSearchResult = { - title?: string; - url?: string; - description?: string; - age?: string; -}; - -type BraveSearchResponse = { - web?: { - results?: BraveSearchResult[]; - }; -}; - -type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; -type BraveLlmContextResponse = { - grounding: { generic?: BraveLlmContextResult[] }; - sources?: { url?: string; hostname?: string; date?: string }[]; -}; - -type BraveConfig = { - mode?: string; -}; - -type PerplexityConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; -type PerplexityTransport = "search_api" | "chat_completions"; -type PerplexityBaseUrlHint = "direct" | "openrouter"; - -type GrokConfig = { - apiKey?: string; - model?: string; - inlineCitations?: boolean; -}; - -type KimiConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type GrokSearchResponse = { - output?: Array<{ - type?: string; - role?: string; - text?: string; // present when type === "output_text" (top-level output_text block) - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - output_text?: string; // deprecated field - kept for backwards compatibility - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -type KimiToolCall = { - id?: string; - type?: string; - function?: { - name?: string; - arguments?: string; - }; -}; - -type KimiMessage = { - role?: string; - content?: string; - reasoning_content?: string; - tool_calls?: KimiToolCall[]; -}; - -type KimiSearchResponse = { - choices?: Array<{ - finish_reason?: string; - message?: KimiMessage; - }>; - search_results?: Array<{ - title?: string; - url?: string; - content?: string; - }>; -}; - -type PerplexitySearchResponse = { - choices?: Array<{ - message?: { - content?: string; - annotations?: Array<{ - type?: string; - url?: string; - url_citation?: { - url?: string; - title?: string; - start_index?: number; - end_index?: number; - }; - }>; - }; - }>; - citations?: string[]; -}; - -type PerplexitySearchApiResult = { - title?: string; - url?: string; - snippet?: string; - date?: string; - last_updated?: string; -}; - -type PerplexitySearchApiResponse = { - results?: PerplexitySearchApiResult[]; - id?: string; -}; - -function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { - const normalizeUrl = (value: unknown): string | undefined => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; - }; - - const topLevel = (data.citations ?? []) - .map(normalizeUrl) - .filter((url): url is string => Boolean(url)); - if (topLevel.length > 0) { - return [...new Set(topLevel)]; - } - - const citations: string[] = []; - for (const choice of data.choices ?? []) { - for (const annotation of choice.message?.annotations ?? []) { - if (annotation.type !== "url_citation") { - continue; - } - const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); - if (url) { - citations.push(url); - } - } - } - - return [...new Set(citations)]; -} - -function extractGrokContent(data: GrokSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - // xAI Responses API format: find the message output with text content - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter((a) => a.type === "url_citation" && typeof a.url === "string") - .map((a) => a.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - // Some xAI responses place output_text blocks directly in the output array - // without a message wrapper. - if ( - output.type === "output_text" && - "text" in output && - typeof output.text === "string" && - output.text - ) { - const rawAnnotations = - "annotations" in output && Array.isArray(output.annotations) ? output.annotations : []; - const urls = rawAnnotations - .filter( - (a: Record) => a.type === "url_citation" && typeof a.url === "string", - ) - .map((a: Record) => a.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - // Fallback: deprecated output_text field - const text = typeof data.output_text === "string" ? data.output_text : undefined; - return { text, annotationCitations: [] }; -} - -type GeminiConfig = { - apiKey?: string; - model?: string; -}; - -type GeminiGroundingResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ - text?: string; - }>; - }; - groundingMetadata?: { - groundingChunks?: Array<{ - web?: { - uri?: string; - title?: string; - }; - }>; - searchEntryPoint?: { - renderedContent?: string; - }; - webSearchQueries?: string[]; - }; - }>; - error?: { - code?: number; - message?: string; - status?: string; - }; -}; - -const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; -const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; - function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") { @@ -548,1344 +32,66 @@ function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: bo return true; } -function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { - const fromConfigRaw = - search && "apiKey" in search - ? normalizeResolvedSecretInputString({ - value: search.apiKey, - path: "tools.web.search.apiKey", - }) - : undefined; - const fromConfig = normalizeSecretInput(fromConfigRaw); - const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); - return fromConfig || fromEnv || undefined; +function readProviderEnvValue(envVars: string[]): string | undefined { + for (const envVar of envVars) { + const value = normalizeSecretInput(process.env[envVar]); + if (value) { + return value; + } + } + return undefined; } -function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { - if (provider === "brave") { - return { - error: "missing_brave_api_key", - message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, - docs: "https://docs.openclaw.ai/tools/web", - }; +function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + return false; } - if (provider === "gemini") { - return { - error: "missing_gemini_api_key", - message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (provider === "grok") { - return { - error: "missing_xai_api_key", - message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (provider === "kimi") { - return { - error: "missing_kimi_api_key", - message: - "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - return { - error: "missing_perplexity_api_key", - message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; + const rawValue = provider.getCredentialValue(search as Record | undefined); + const fromConfig = normalizeSecretInput( + normalizeResolvedSecretInputString({ + value: rawValue, + path: + providerId === "brave" + ? "tools.web.search.apiKey" + : `tools.web.search.${providerId}.apiKey`, + }), + ); + return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); } -function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { +function resolveSearchProvider(search?: WebSearchConfig): string { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); const raw = search && "provider" in search && typeof search.provider === "string" ? search.provider.trim().toLowerCase() : ""; - if (raw === "brave") { - return "brave"; - } - if (raw === "gemini") { - return "gemini"; - } - if (raw === "grok") { - return "grok"; - } - if (raw === "kimi") { - return "kimi"; - } - if (raw === "perplexity") { - return "perplexity"; + + if (raw) { + const explicit = providers.find((provider) => provider.id === raw); + if (explicit) { + return explicit.id; + } } - // Auto-detect provider from available API keys (alphabetical order) - if (raw === "") { - // Brave - if (resolveSearchApiKey(search)) { + if (!raw) { + for (const provider of providers) { + if (!hasProviderCredential(provider.id, search)) { + continue; + } logVerbose( - 'web_search: no provider configured, auto-detected "brave" from available API keys', + `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, ); - return "brave"; - } - // Gemini - const geminiConfig = resolveGeminiConfig(search); - if (resolveGeminiApiKey(geminiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "gemini" from available API keys', - ); - return "gemini"; - } - // Grok - const grokConfig = resolveGrokConfig(search); - if (resolveGrokApiKey(grokConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "grok" from available API keys', - ); - return "grok"; - } - // Kimi - const kimiConfig = resolveKimiConfig(search); - if (resolveKimiApiKey(kimiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "kimi" from available API keys', - ); - return "kimi"; - } - // Perplexity - const perplexityConfig = resolvePerplexityConfig(search); - const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); - if (perplexityKey) { - logVerbose( - 'web_search: no provider configured, auto-detected "perplexity" from available API keys', - ); - return "perplexity"; + return provider.id; } } - return "brave"; -} - -function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { - if (!search || typeof search !== "object") { - return {}; - } - const brave = "brave" in search ? search.brave : undefined; - if (!brave || typeof brave !== "object") { - return {}; - } - return brave as BraveConfig; -} - -function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { - return brave.mode === "llm-context" ? "llm-context" : "web"; -} - -function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { - if (!search || typeof search !== "object") { - return {}; - } - const perplexity = "perplexity" in search ? search.perplexity : undefined; - if (!perplexity || typeof perplexity !== "object") { - return {}; - } - return perplexity as PerplexityConfig; -} - -function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; -} { - const fromConfig = normalizeApiKey(perplexity?.apiKey); - if (fromConfig) { - return { apiKey: fromConfig, source: "config" }; - } - - const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); - if (fromEnvPerplexity) { - return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; - } - - const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); - if (fromEnvOpenRouter) { - return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; - } - - return { apiKey: undefined, source: "none" }; -} - -function normalizeApiKey(key: unknown): string { - return normalizeSecretInput(key); -} - -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { - if (!apiKey) { - return undefined; - } - const normalized = apiKey.toLowerCase(); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function resolvePerplexityBaseUrl( - perplexity?: PerplexityConfig, - authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret - configuredKey?: string, -): string { - const fromConfig = - perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" - ? perplexity.baseUrl.trim() - : ""; - if (fromConfig) { - return fromConfig; - } - if (authSource === "perplexity_env") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (authSource === "openrouter_env") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - if (authSource === "config") { - const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); - if (inferred === "openrouter") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - return PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; -} - -function resolvePerplexityModel(perplexity?: PerplexityConfig): string { - const fromConfig = - perplexity && "model" in perplexity && typeof perplexity.model === "string" - ? perplexity.model.trim() - : ""; - return fromConfig || DEFAULT_PERPLEXITY_MODEL; -} - -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { - const trimmed = baseUrl.trim(); - if (!trimmed) { - return false; - } - try { - return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; - } catch { - return false; - } -} - -function resolvePerplexityRequestModel(baseUrl: string, model: string): string { - if (!isDirectPerplexityBaseUrl(baseUrl)) { - return model; - } - return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; -} - -function resolvePerplexityTransport(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; - baseUrl: string; - model: string; - transport: PerplexityTransport; -} { - const auth = resolvePerplexityApiKey(perplexity); - const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); - const model = resolvePerplexityModel(perplexity); - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return { - ...auth, - baseUrl, - model, - transport: - hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", - }; -} - -function resolvePerplexitySchemaTransportHint( - perplexity?: PerplexityConfig, -): PerplexityTransport | undefined { - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return hasLegacyOverride ? "chat_completions" : undefined; -} - -function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { - if (!search || typeof search !== "object") { - return {}; - } - const grok = "grok" in search ? search.grok : undefined; - if (!grok || typeof grok !== "object") { - return {}; - } - return grok as GrokConfig; -} - -function resolveGrokApiKey(grok?: GrokConfig): string | undefined { - const fromConfig = normalizeApiKey(grok?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); - return fromEnv || undefined; -} - -function resolveGrokModel(grok?: GrokConfig): string { - const fromConfig = - grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; - return fromConfig || DEFAULT_GROK_MODEL; -} - -function resolveGrokInlineCitations(grok?: GrokConfig): boolean { - return grok?.inlineCitations === true; -} - -function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const kimi = "kimi" in search ? search.kimi : undefined; - if (!kimi || typeof kimi !== "object") { - return {}; - } - return kimi as KimiConfig; -} - -function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { - const fromConfig = normalizeApiKey(kimi?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); - if (fromEnvKimi) { - return fromEnvKimi; - } - const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); - return fromEnvMoonshot || undefined; -} - -function resolveKimiModel(kimi?: KimiConfig): string { - const fromConfig = - kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; - return fromConfig || DEFAULT_KIMI_MODEL; -} - -function resolveKimiBaseUrl(kimi?: KimiConfig): string { - const fromConfig = - kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; - return fromConfig || DEFAULT_KIMI_BASE_URL; -} - -function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const gemini = "gemini" in search ? search.gemini : undefined; - if (!gemini || typeof gemini !== "object") { - return {}; - } - return gemini as GeminiConfig; -} - -function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { - const fromConfig = normalizeApiKey(gemini?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); - return fromEnv || undefined; -} - -function resolveGeminiModel(gemini?: GeminiConfig): string { - const fromConfig = - gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : ""; - return fromConfig || DEFAULT_GEMINI_MODEL; -} - -async function withTrustedWebSearchEndpoint( - params: { - url: string; - timeoutSeconds: number; - init: RequestInit; - }, - run: (response: Response) => Promise, -): Promise { - return withTrustedWebToolsEndpoint( - { - url: params.url, - init: params.init, - timeoutSeconds: params.timeoutSeconds, - }, - async ({ response }) => run(response), - ); -} - -async function runGeminiSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { - const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": params.apiKey, - }, - body: JSON.stringify({ - contents: [ - { - parts: [{ text: params.query }], - }, - ], - tools: [{ google_search: {} }], - }), - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - // Strip API key from any error detail to prevent accidental key leakage in logs - const safeDetail = (detailResult.text || res.statusText).replace( - /key=[^&\s]+/gi, - "key=***", - ); - throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); - } - - let data: GeminiGroundingResponse; - try { - data = (await res.json()) as GeminiGroundingResponse; - } catch (err) { - const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err }); - } - - if (data.error) { - const rawMsg = data.error.message || data.error.status || "unknown"; - const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`); - } - - const candidate = data.candidates?.[0]; - const content = - candidate?.content?.parts - ?.map((p) => p.text) - .filter(Boolean) - .join("\n") ?? "No response"; - - const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? []; - const rawCitations = groundingChunks - .filter((chunk) => chunk.web?.uri) - .map((chunk) => ({ - url: chunk.web!.uri!, - title: chunk.web?.title || undefined, - })); - - // Resolve Google grounding redirect URLs to direct URLs with concurrency cap. - // Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe. - const MAX_CONCURRENT_REDIRECTS = 10; - const citations: Array<{ url: string; title?: string }> = []; - for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) { - const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS); - const resolved = await Promise.all( - batch.map(async (citation) => { - const resolvedUrl = await resolveCitationRedirectUrl(citation.url); - return { ...citation, url: resolvedUrl }; - }), - ); - citations.push(...resolved); - } - - return { content, citations }; - }, - ); -} - -function resolveSearchCount(value: unknown, fallback: number): number { - const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; - const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); - return clamped; -} - -function normalizeBraveSearchLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); - if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { - return undefined; - } - return canonical; -} - -function normalizeBraveUiLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const match = trimmed.match(BRAVE_UI_LANG_LOCALE); - if (!match) { - return undefined; - } - const [, language, region] = match; - return `${language.toLowerCase()}-${region.toUpperCase()}`; -} - -function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { - search_lang?: string; - ui_lang?: string; - invalidField?: "search_lang" | "ui_lang"; -} { - const rawSearchLang = params.search_lang?.trim() || undefined; - const rawUiLang = params.ui_lang?.trim() || undefined; - let searchLangCandidate = rawSearchLang; - let uiLangCandidate = rawUiLang; - - // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. - if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { - searchLangCandidate = rawUiLang; - uiLangCandidate = rawSearchLang; - } - - const search_lang = normalizeBraveSearchLang(searchLangCandidate); - if (searchLangCandidate && !search_lang) { - return { invalidField: "search_lang" }; - } - - const ui_lang = normalizeBraveUiLang(uiLangCandidate); - if (uiLangCandidate && !ui_lang) { - return { invalidField: "ui_lang" }; - } - - return { search_lang, ui_lang }; -} - -/** - * Normalizes freshness shortcut to the provider's expected format. - * Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year). - * For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD). - */ -function normalizeFreshness( - value: string | undefined, - provider: (typeof SEARCH_PROVIDERS)[number], -): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - - if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) { - return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower]; - } - - if (PERPLEXITY_RECENCY_VALUES.has(lower)) { - return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower]; - } - - // Brave date range support - if (provider === "brave") { - const match = trimmed.match(BRAVE_FRESHNESS_RANGE); - if (match) { - const [, start, end] = match; - if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { - return `${start}to${end}`; - } - } - } - - return undefined; -} - -function isValidIsoDate(value: string): boolean { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - return false; - } - const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10)); - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - return false; - } - - const date = new Date(Date.UTC(year, month - 1, day)); - return ( - date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day - ); -} - -function resolveSiteName(url: string | undefined): string | undefined { - if (!url) { - return undefined; - } - try { - return new URL(url).hostname; - } catch { - return undefined; - } -} - -async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`); -} - -async function runPerplexitySearchApi(params: { - query: string; - apiKey: string; - count: number; - timeoutSeconds: number; - country?: string; - searchDomainFilter?: string[]; - searchRecencyFilter?: string; - searchLanguageFilter?: string[]; - searchAfterDate?: string; - searchBeforeDate?: string; - maxTokens?: number; - maxTokensPerPage?: number; -}): Promise< - Array<{ title: string; url: string; description: string; published?: string; siteName?: string }> -> { - const body: Record = { - query: params.query, - max_results: params.count, - }; - - if (params.country) { - body.country = params.country; - } - if (params.searchDomainFilter && params.searchDomainFilter.length > 0) { - body.search_domain_filter = params.searchDomainFilter; - } - if (params.searchRecencyFilter) { - body.search_recency_filter = params.searchRecencyFilter; - } - if (params.searchLanguageFilter && params.searchLanguageFilter.length > 0) { - body.search_language_filter = params.searchLanguageFilter; - } - if (params.searchAfterDate) { - body.search_after_date = params.searchAfterDate; - } - if (params.searchBeforeDate) { - body.search_before_date = params.searchBeforeDate; - } - if (params.maxTokens !== undefined) { - body.max_tokens = params.maxTokens; - } - if (params.maxTokensPerPage !== undefined) { - body.max_tokens_per_page = params.maxTokensPerPage; - } - - return withTrustedWebSearchEndpoint( - { - url: PERPLEXITY_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity Search"); - } - - const data = (await res.json()) as PerplexitySearchApiResponse; - const results = Array.isArray(data.results) ? data.results : []; - - return results.map((entry) => { - const title = entry.title ?? ""; - const url = entry.url ?? ""; - const snippet = entry.snippet ?? ""; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: snippet ? wrapWebContent(snippet, "web_search") : "", - published: entry.date ?? undefined, - siteName: resolveSiteName(url) || undefined, - }; - }); - }, - ); -} - -async function runPerplexitySearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; - freshness?: string; -}): Promise<{ content: string; citations: string[] }> { - const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); - const endpoint = `${baseUrl}/chat/completions`; - const model = resolvePerplexityRequestModel(baseUrl, params.model); - - const body: Record = { - model, - messages: [ - { - role: "user", - content: params.query, - }, - ], - }; - - if (params.freshness) { - body.search_recency_filter = params.freshness; - } - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity"); - } - - const data = (await res.json()) as PerplexitySearchResponse; - const content = data.choices?.[0]?.message?.content ?? "No response"; - // Prefer top-level citations; fall back to OpenRouter-style message annotations. - const citations = extractPerplexityCitations(data); - - return { content, citations }; - }, - ); -} - -async function runGrokSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; - inlineCitations: boolean; -}): Promise<{ - content: string; - citations: string[]; - inlineCitations?: GrokSearchResponse["inline_citations"]; -}> { - const body: Record = { - model: params.model, - input: [ - { - role: "user", - content: params.query, - }, - ], - tools: [{ type: "web_search" }], - }; - - // Note: xAI's /v1/responses endpoint does not support the `include` - // parameter (returns 400 "Argument not supported: include"). Inline - // citations are returned automatically when available — we just parse - // them from the response without requesting them explicitly (#12910). - - return withTrustedWebSearchEndpoint( - { - url: XAI_API_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "xAI"); - } - - const data = (await res.json()) as GrokSearchResponse; - const { text: extractedText, annotationCitations } = extractGrokContent(data); - const content = extractedText ?? "No response"; - // Prefer top-level citations; fall back to annotation-derived ones - const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations; - const inlineCitations = data.inline_citations; - - return { content, citations, inlineCitations }; - }, - ); -} - -function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { - const content = message?.content?.trim(); - if (content) { - return content; - } - const reasoning = message?.reasoning_content?.trim(); - return reasoning || undefined; -} - -function extractKimiCitations(data: KimiSearchResponse): string[] { - const citations = (data.search_results ?? []) - .map((entry) => entry.url?.trim()) - .filter((url): url is string => Boolean(url)); - - for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { - const rawArguments = toolCall.function?.arguments; - if (!rawArguments) { - continue; - } - try { - const parsed = JSON.parse(rawArguments) as { - search_results?: Array<{ url?: string }>; - url?: string; - }; - if (typeof parsed.url === "string" && parsed.url.trim()) { - citations.push(parsed.url.trim()); - } - for (const result of parsed.search_results ?? []) { - if (typeof result.url === "string" && result.url.trim()) { - citations.push(result.url.trim()); - } - } - } catch { - // ignore malformed tool arguments - } - } - - return [...new Set(citations)]; -} - -function buildKimiToolResultContent(data: KimiSearchResponse): string { - return JSON.stringify({ - search_results: (data.search_results ?? []).map((entry) => ({ - title: entry.title ?? "", - url: entry.url ?? "", - content: entry.content ?? "", - })), - }); -} - -async function runKimiSearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: string[] }> { - const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); - const endpoint = `${baseUrl}/chat/completions`; - const messages: Array> = [ - { - role: "user", - content: params.query, - }, - ]; - const collectedCitations = new Set(); - const MAX_ROUNDS = 3; - - for (let round = 0; round < MAX_ROUNDS; round += 1) { - const nextResult = await withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - messages, - tools: [KIMI_WEB_SEARCH_TOOL], - }), - }, - }, - async ( - res, - ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Kimi"); - } - - const data = (await res.json()) as KimiSearchResponse; - for (const citation of extractKimiCitations(data)) { - collectedCitations.add(citation); - } - const choice = data.choices?.[0]; - const message = choice?.message; - const text = extractKimiMessageText(message); - const toolCalls = message?.tool_calls ?? []; - - if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - messages.push({ - role: "assistant", - content: message?.content ?? "", - ...(message?.reasoning_content - ? { - reasoning_content: message.reasoning_content, - } - : {}), - tool_calls: toolCalls, - }); - - const toolContent = buildKimiToolResultContent(data); - let pushedToolResult = false; - for (const toolCall of toolCalls) { - const toolCallId = toolCall.id?.trim(); - if (!toolCallId) { - continue; - } - pushedToolResult = true; - messages.push({ - role: "tool", - tool_call_id: toolCallId, - content: toolContent, - }); - } - - if (!pushedToolResult) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - return { done: false }; - }, - ); - - if (nextResult.done) { - return { content: nextResult.content, citations: nextResult.citations }; - } - } - - return { - content: "Search completed but no final answer was produced.", - citations: [...collectedCitations], - }; -} - -function mapBraveLlmContextResults( - data: BraveLlmContextResponse, -): { url: string; title: string; snippets: string[]; siteName?: string }[] { - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - return genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), - siteName: resolveSiteName(entry.url) || undefined, - })); -} - -async function runBraveLlmContextSearch(params: { - query: string; - apiKey: string; - timeoutSeconds: number; - country?: string; - search_lang?: string; - freshness?: string; -}): Promise<{ - results: Array<{ - url: string; - title: string; - snippets: string[]; - siteName?: string; - }>; - sources?: BraveLlmContextResponse["sources"]; -}> { - const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); - url.searchParams.set("q", params.query); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang) { - url.searchParams.set("search_lang", params.search_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } - - return withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveLlmContextResponse; - const mapped = mapBraveLlmContextResults(data); - - return { results: mapped, sources: data.sources }; - }, - ); -} - -async function runWebSearch(params: { - query: string; - count: number; - apiKey: string; - timeoutSeconds: number; - cacheTtlMs: number; - provider: (typeof SEARCH_PROVIDERS)[number]; - country?: string; - language?: string; - search_lang?: string; - ui_lang?: string; - freshness?: string; - dateAfter?: string; - dateBefore?: string; - searchDomainFilter?: string[]; - maxTokens?: number; - maxTokensPerPage?: number; - perplexityBaseUrl?: string; - perplexityModel?: string; - perplexityTransport?: PerplexityTransport; - grokModel?: string; - grokInlineCitations?: boolean; - geminiModel?: string; - kimiBaseUrl?: string; - kimiModel?: string; - braveMode?: "web" | "llm-context"; -}): Promise> { - const effectiveBraveMode = params.braveMode ?? "web"; - const providerSpecificKey = - params.provider === "perplexity" - ? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` - : params.provider === "grok" - ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` - : params.provider === "gemini" - ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) - : params.provider === "kimi" - ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` - : ""; - const cacheKey = normalizeCacheKey( - params.provider === "brave" && effectiveBraveMode === "llm-context" - ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` - : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, - ); - const cached = readCache(SEARCH_CACHE, cacheKey); - if (cached) { - return { ...cached.value, cached: true }; - } - - const start = Date.now(); - - if (params.provider === "perplexity") { - if (params.perplexityTransport === "chat_completions") { - const { content, citations } = await runPerplexitySearch({ - query: params.query, - apiKey: params.apiKey, - baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, - timeoutSeconds: params.timeoutSeconds, - freshness: params.freshness, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content, "web_search"), - citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - const results = await runPerplexitySearchApi({ - query: params.query, - apiKey: params.apiKey, - count: params.count, - timeoutSeconds: params.timeoutSeconds, - country: params.country, - searchDomainFilter: params.searchDomainFilter, - searchRecencyFilter: params.freshness, - searchLanguageFilter: params.language ? [params.language] : undefined, - searchAfterDate: params.dateAfter ? isoToPerplexityDate(params.dateAfter) : undefined, - searchBeforeDate: params.dateBefore ? isoToPerplexityDate(params.dateBefore) : undefined, - maxTokens: params.maxTokens, - maxTokensPerPage: params.maxTokensPerPage, - }); - - const payload = { - query: params.query, - provider: params.provider, - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "grok") { - const { content, citations, inlineCitations } = await runGrokSearch({ - query: params.query, - apiKey: params.apiKey, - model: params.grokModel ?? DEFAULT_GROK_MODEL, - timeoutSeconds: params.timeoutSeconds, - inlineCitations: params.grokInlineCitations ?? false, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.grokModel ?? DEFAULT_GROK_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content), - citations, - inlineCitations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "kimi") { - const { content, citations } = await runKimiSearch({ - query: params.query, - apiKey: params.apiKey, - baseUrl: params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL, - model: params.kimiModel ?? DEFAULT_KIMI_MODEL, - timeoutSeconds: params.timeoutSeconds, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.kimiModel ?? DEFAULT_KIMI_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content), - citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "gemini") { - const geminiResult = await runGeminiSearch({ - query: params.query, - apiKey: params.apiKey, - model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, - timeoutSeconds: params.timeoutSeconds, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, - tookMs: Date.now() - start, // Includes redirect URL resolution time - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(geminiResult.content), - citations: geminiResult.citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider !== "brave") { - throw new Error("Unsupported web search provider."); - } - - if (effectiveBraveMode === "llm-context") { - const { results: llmResults, sources } = await runBraveLlmContextSearch({ - query: params.query, - apiKey: params.apiKey, - timeoutSeconds: params.timeoutSeconds, - country: params.country, - search_lang: params.search_lang, - freshness: params.freshness, - }); - - const mapped = llmResults.map((entry) => ({ - title: entry.title ? wrapWebContent(entry.title, "web_search") : "", - url: entry.url, - snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), - siteName: entry.siteName, - })); - - const payload = { - query: params.query, - provider: params.provider, - mode: "llm-context" as const, - count: mapped.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results: mapped, - sources, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - const url = new URL(BRAVE_SEARCH_ENDPOINT); - url.searchParams.set("q", params.query); - url.searchParams.set("count", String(params.count)); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang || params.language) { - url.searchParams.set("search_lang", (params.search_lang || params.language)!); - } - if (params.ui_lang) { - url.searchParams.set("ui_lang", params.ui_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } else if (params.dateAfter && params.dateBefore) { - url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); - } else if (params.dateAfter) { - url.searchParams.set( - "freshness", - `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, - ); - } else if (params.dateBefore) { - url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); - } - - const mapped = await withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveSearchResponse; - const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; - return results.map((entry) => { - const description = entry.description ?? ""; - const title = entry.title ?? ""; - const url = entry.url ?? ""; - const rawSiteName = resolveSiteName(url); - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, // Keep raw for tool chaining - description: description ? wrapWebContent(description, "web_search") : "", - published: entry.age || undefined, - siteName: rawSiteName || undefined, - }; - }); - }, - ); - - const payload = { - query: params.query, - provider: params.provider, - count: mapped.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results: mapped, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; + return providers[0]?.id ?? "brave"; } export function createWebSearchTool(options?: { @@ -1898,325 +104,45 @@ export function createWebSearchTool(options?: { return null; } - const provider = + const providers = resolvePluginWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }); + if (providers.length === 0) { + return null; + } + + const providerId = options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured ?? resolveSearchProvider(search); - const perplexityConfig = resolvePerplexityConfig(search); - const perplexitySchemaTransportHint = - options?.runtimeWebSearch?.perplexityTransport ?? - resolvePerplexitySchemaTransportHint(perplexityConfig); - const grokConfig = resolveGrokConfig(search); - const geminiConfig = resolveGeminiConfig(search); - const kimiConfig = resolveKimiConfig(search); - const braveConfig = resolveBraveConfig(search); - const braveMode = resolveBraveMode(braveConfig); + const provider = + providers.find((entry) => entry.id === providerId) ?? + providers.find((entry) => entry.id === resolveSearchProvider(search)) ?? + providers[0]; + if (!provider) { + return null; + } - const description = - provider === "perplexity" - ? perplexitySchemaTransportHint === "chat_completions" - ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." - : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." - : provider === "grok" - ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." - : provider === "kimi" - ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." - : provider === "gemini" - ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." - : braveMode === "llm-context" - ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + const definition = provider.createTool({ + config: options?.config, + searchConfig: search as Record | undefined, + runtimeMetadata: options?.runtimeWebSearch, + }); + if (!definition) { + return null; + } return { label: "Web Search", name: "web_search", - description, - parameters: createWebSearchSchema({ - provider, - perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, - }), - execute: async (_toolCallId, args) => { - // Resolve Perplexity auth/transport lazily at execution time so unrelated providers - // do not touch Perplexity-only credential surfaces during tool construction. - const perplexityRuntime = - provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; - const apiKey = - provider === "perplexity" - ? perplexityRuntime?.apiKey - : provider === "grok" - ? resolveGrokApiKey(grokConfig) - : provider === "kimi" - ? resolveKimiApiKey(kimiConfig) - : provider === "gemini" - ? resolveGeminiApiKey(geminiConfig) - : resolveSearchApiKey(search); - - if (!apiKey) { - return jsonResult(missingSearchKeyPayload(provider)); - } - - const supportsStructuredPerplexityFilters = - provider === "perplexity" && perplexityRuntime?.transport === "search_api"; - const params = args as Record; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; - const country = readStringParam(params, "country"); - if ( - country && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_country", - message: - provider === "perplexity" - ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const language = readStringParam(params, "language"); - if ( - language && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_language", - message: - provider === "perplexity" - ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (language && provider === "perplexity" && !/^[a-z]{2}$/i.test(language)) { - return jsonResult({ - error: "invalid_language", - message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); - // For Brave, accept both `language` (unified) and `search_lang` - const normalizedBraveLanguageParams = - provider === "brave" - ? normalizeBraveLanguageParams({ search_lang: search_lang || language, ui_lang }) - : { search_lang: language, ui_lang }; - if (normalizedBraveLanguageParams.invalidField === "search_lang") { - return jsonResult({ - error: "invalid_search_lang", - message: - "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (normalizedBraveLanguageParams.invalidField === "ui_lang") { - return jsonResult({ - error: "invalid_ui_lang", - message: "ui_lang must be a language-region locale like 'en-US'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; - const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; - if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_ui_lang", - message: - "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawFreshness = readStringParam(params, "freshness"); - if (rawFreshness && provider !== "brave" && provider !== "perplexity") { - return jsonResult({ - error: "unsupported_freshness", - message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (rawFreshness && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_freshness", - message: - "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; - if (rawFreshness && !freshness) { - return jsonResult({ - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (rawFreshness && (rawDateAfter || rawDateBefore)) { - return jsonResult({ - error: "conflicting_time_filters", - message: - "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if ( - (rawDateAfter || rawDateBefore) && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_date_filter", - message: - provider === "perplexity" - ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." - : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_date_filter", - message: - "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - if (rawDateAfter && !dateAfter) { - return jsonResult({ - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateBefore && !dateBefore) { - return jsonResult({ - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return jsonResult({ - error: "invalid_date_range", - message: "date_after must be before date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const domainFilter = readStringArrayParam(params, "domain_filter"); - if ( - domainFilter && - domainFilter.length > 0 && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_domain_filter", - message: - provider === "perplexity" - ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - - if (domainFilter && domainFilter.length > 0) { - const hasDenylist = domainFilter.some((d) => d.startsWith("-")); - const hasAllowlist = domainFilter.some((d) => !d.startsWith("-")); - if (hasDenylist && hasAllowlist) { - return jsonResult({ - error: "invalid_domain_filter", - message: - "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (domainFilter.length > 20) { - return jsonResult({ - error: "invalid_domain_filter", - message: "domain_filter supports a maximum of 20 domains.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - } - - const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); - const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); - if ( - provider === "perplexity" && - perplexityRuntime?.transport === "chat_completions" && - (maxTokens !== undefined || maxTokensPerPage !== undefined) - ) { - return jsonResult({ - error: "unsupported_content_budget", - message: - "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - - const result = await runWebSearch({ - query, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - apiKey, - timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), - cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - provider, - country, - language, - search_lang: resolvedSearchLang, - ui_lang: resolvedUiLang, - freshness, - dateAfter, - dateBefore, - searchDomainFilter: domainFilter, - maxTokens: maxTokens ?? undefined, - maxTokensPerPage: maxTokensPerPage ?? undefined, - perplexityBaseUrl: perplexityRuntime?.baseUrl, - perplexityModel: perplexityRuntime?.model, - perplexityTransport: perplexityRuntime?.transport, - grokModel: resolveGrokModel(grokConfig), - grokInlineCitations: resolveGrokInlineCitations(grokConfig), - geminiModel: resolveGeminiModel(geminiConfig), - kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), - kimiModel: resolveKimiModel(kimiConfig), - braveMode, - }); - return jsonResult(result); - }, + description: definition.description, + parameters: definition.parameters, + execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)), }; } export const __testing = { + ...coreTesting, resolveSearchProvider, - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeBraveLanguageParams, - normalizeFreshness, - normalizeToIsoDate, - isoToPerplexityDate, - SEARCH_CACHE, - FRESHNESS_TO_RECENCY, - RECENCY_TO_FRESHNESS, - resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, - resolveRedirectUrl: resolveCitationRedirectUrl, - resolveBraveMode, - mapBraveLlmContextResults, -} as const; +}; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 10e2df9f81b..93451a9d6e9 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -244,18 +244,66 @@ describe("setupSearch", () => { }); it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { + const originalPerplexity = process.env.PERPLEXITY_API_KEY; + const originalOpenRouter = process.env.OPENROUTER_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - secretInputMode: "ref", // pragma: allowlist secret - }); - expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "PERPLEXITY_API_KEY", // pragma: allowlist secret - }); - expect(prompter.text).not.toHaveBeenCalled(); + try { + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "PERPLEXITY_API_KEY", // pragma: allowlist secret + }); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (originalPerplexity === undefined) { + delete process.env.PERPLEXITY_API_KEY; + } else { + process.env.PERPLEXITY_API_KEY = originalPerplexity; + } + if (originalOpenRouter === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = originalOpenRouter; + } + } + }); + + it("prefers detected OPENROUTER_API_KEY SecretRef for perplexity ref mode", async () => { + const originalPerplexity = process.env.PERPLEXITY_API_KEY; + const originalOpenRouter = process.env.OPENROUTER_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + process.env.OPENROUTER_API_KEY = "sk-or-test"; + const cfg: OpenClawConfig = {}; + try { + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "OPENROUTER_API_KEY", // pragma: allowlist secret + }); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (originalPerplexity === undefined) { + delete process.env.PERPLEXITY_API_KEY; + } else { + process.env.PERPLEXITY_API_KEY = originalPerplexity; + } + if (originalOpenRouter === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = originalOpenRouter; + } + } }); it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => { diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index df2f4643b60..d1281fe3fc7 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,11 +6,12 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; -export type SearchProvider = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; +export type SearchProvider = string; type SearchProviderEntry = { value: SearchProvider; @@ -21,48 +22,17 @@ type SearchProviderEntry = { signupUrl: string; }; -export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ - { - value: "brave", - label: "Brave Search", - hint: "Structured results · country/language/time filters", - envKeys: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - }, - { - value: "gemini", - label: "Gemini (Google Search)", - hint: "Google Search grounding · AI-synthesized", - envKeys: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - }, - { - value: "grok", - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envKeys: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - }, - { - value: "kimi", - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - }, - { - value: "perplexity", - label: "Perplexity Search", - hint: "Structured results · domain/country/language/time filters", - envKeys: ["PERPLEXITY_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - }, -] as const; +export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = + resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }).map((provider) => ({ + value: provider.id, + label: provider.label, + hint: provider.hint, + envKeys: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + })); export function hasKeyInEnv(entry: SearchProviderEntry): boolean { return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); @@ -70,18 +40,11 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean { function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { const search = config.tools?.web?.search; - switch (provider) { - case "brave": - return search?.apiKey; - case "gemini": - return search?.gemini?.apiKey; - case "grok": - return search?.grok?.apiKey; - case "kimi": - return search?.kimi?.apiKey; - case "perplexity": - return search?.perplexity?.apiKey; - } + const entry = resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + }).find((candidate) => candidate.id === provider); + return entry?.getCredentialValue(search as Record | undefined); } /** Returns the plaintext key string, or undefined for SecretRefs/missing. */ @@ -128,22 +91,12 @@ export function applySearchKey( key: SecretInput, ): OpenClawConfig { const search = { ...config.tools?.web?.search, provider, enabled: true }; - switch (provider) { - case "brave": - search.apiKey = key; - break; - case "gemini": - search.gemini = { ...search.gemini, apiKey: key }; - break; - case "grok": - search.grok = { ...search.grok, apiKey: key }; - break; - case "kimi": - search.kimi = { ...search.kimi, apiKey: key }; - break; - case "perplexity": - search.perplexity = { ...search.perplexity, apiKey: key }; - break; + const entry = resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + }).find((candidate) => candidate.id === provider); + if (entry) { + entry.setCredentialValue(search as Record, key); } return { ...config, @@ -225,7 +178,7 @@ export async function setupSearch( return SEARCH_PROVIDER_OPTIONS[0].value; })(); - type PickerValue = SearchProvider | "__skip__"; + type PickerValue = string; const choice = await prompter.select({ message: "Search provider", options: [ @@ -236,7 +189,7 @@ export async function setupSearch( hint: "Configure later with openclaw configure --section web", }, ], - initialValue: defaultProvider as PickerValue, + initialValue: defaultProvider, }); if (choice === "__skip__") { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 7ddb4ca3ab4..9df692962f2 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -6,6 +6,40 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn() }, })); +vi.mock("../plugins/web-search-providers.js", () => { + const getScoped = (key: string) => (search?: Record) => + (search?.[key] as { apiKey?: unknown } | undefined)?.apiKey; + return { + resolvePluginWebSearchProviders: () => [ + { + id: "brave", + envVars: ["BRAVE_API_KEY"], + getCredentialValue: (search?: Record) => search?.apiKey, + }, + { + id: "gemini", + envVars: ["GEMINI_API_KEY"], + getCredentialValue: getScoped("gemini"), + }, + { + id: "grok", + envVars: ["XAI_API_KEY"], + getCredentialValue: getScoped("grok"), + }, + { + id: "kimi", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + getCredentialValue: getScoped("kimi"), + }, + { + id: "perplexity", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + getCredentialValue: getScoped("perplexity"), + }, + ], + }; +}); + const { __testing } = await import("../agents/tools/web-search.js"); const { resolveSearchProvider } = __testing; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6f32ee0d151..13f6842d1e1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -359,6 +359,7 @@ function createPluginRecord(params: { hookNames: [], channelIds: [], providerIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 8e04106dc9c..42e9c236909 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -47,6 +47,7 @@ import type { PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, + WebSearchProviderPlugin, } from "./types.js"; export type PluginToolRegistration = { @@ -103,6 +104,14 @@ export type PluginProviderRegistration = { rootDir?: string; }; +export type PluginWebSearchProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: WebSearchProviderPlugin; + source: string; + rootDir?: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -147,6 +156,7 @@ export type PluginRecord = { hookNames: string[]; channelIds: string[]; providerIds: string[]; + webSearchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -166,6 +176,7 @@ export type PluginRegistry = { channels: PluginChannelRegistration[]; channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; + webSearchProviders: PluginWebSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; cliRegistrars: PluginCliRegistration[]; @@ -210,6 +221,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channels: [], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], @@ -541,6 +553,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => { + const id = provider.id.trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "web search provider registration missing id", + }); + return; + } + const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `web search provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + record.webSearchProviderIds.push(id); + registry.webSearchProviders.push({ + pluginId: record.id, + pluginName: record.name, + provider, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerCli = ( record: PluginRecord, registrar: OpenClawPluginCliRegistrar, @@ -749,6 +792,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerChannel: (registration) => registerChannel(record, registration, registrationMode), registerProvider: registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {}, + registerWebSearchProvider: + registrationMode === "full" + ? (provider) => registerWebSearchProvider(record, provider) + : () => {}, registerGatewayMethod: registrationMode === "full" ? (method, handler) => registerGatewayMethod(record, method, handler) @@ -818,6 +865,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool, registerChannel, registerProvider, + registerWebSearchProvider, registerGatewayMethod, registerCli, registerService, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 09a706a51ea..d96a8c65d8d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -25,6 +25,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -565,6 +566,34 @@ export type ProviderPlugin = { onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise; }; +export type WebSearchProviderId = string; + +export type WebSearchProviderToolDefinition = { + description: string; + parameters: Record; + execute: (args: Record) => Promise>; +}; + +export type WebSearchProviderContext = { + config?: OpenClawConfig; + searchConfig?: Record; + runtimeMetadata?: RuntimeWebSearchMetadata; +}; + +export type WebSearchProviderPlugin = { + id: WebSearchProviderId; + label: string; + hint: string; + envVars: string[]; + placeholder: string; + signupUrl: string; + docsUrl?: string; + autoDetectOrder?: number; + getCredentialValue: (searchConfig?: Record) => unknown; + setCredentialValue: (searchConfigTarget: Record, value: unknown) => void; + createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null; +}; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -868,6 +897,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** * Register a custom command that bypasses the LLM agent. diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts new file mode 100644 index 00000000000..af794d075c9 --- /dev/null +++ b/src/plugins/web-search-providers.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; + +const loadOpenClawPluginsMock = vi.fn(); + +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +describe("resolvePluginWebSearchProviders", () => { + beforeEach(() => { + loadOpenClawPluginsMock.mockReset(); + loadOpenClawPluginsMock.mockReturnValue({ + webSearchProviders: [ + { + pluginId: "web-search-gemini", + provider: { + id: "gemini", + label: "Gemini", + hint: "hint", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://example.com", + autoDetectOrder: 20, + }, + }, + { + pluginId: "web-search-brave", + provider: { + id: "brave", + label: "Brave", + hint: "hint", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://example.com", + autoDetectOrder: 10, + }, + }, + ], + }); + }); + + it("forwards an explicit env to plugin loading", () => { + const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; + + const providers = resolvePluginWebSearchProviders({ + workspaceDir: "/workspace/explicit", + env, + }); + + expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/workspace/explicit", + env, + }), + ); + }); + + it("can augment restrictive allowlists for bundled compatibility", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledAllowlistCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining([ + "openrouter", + "web-search-brave", + "web-search-perplexity", + ]), + }), + }), + }), + ); + }); + + it("auto-enables bundled web search provider plugins when entries are missing", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + entries: { + openrouter: { enabled: true }, + }, + }, + }, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + openrouter: { enabled: true }, + "web-search-brave": { enabled: true }, + "web-search-gemini": { enabled: true }, + "web-search-grok": { enabled: true }, + moonshot: { enabled: true }, + "web-search-perplexity": { enabled: true }, + }), + }), + }), + }), + ); + }); + + it("preserves explicit bundled provider entry state", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + entries: { + "web-search-perplexity": { enabled: false }, + }, + }, + }, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + "web-search-perplexity": { enabled: false }, + }), + }), + }), + }), + ); + }); +}); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts new file mode 100644 index 00000000000..1c5b7fb15e6 --- /dev/null +++ b/src/plugins/web-search-providers.ts @@ -0,0 +1,110 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; +import { createPluginLoaderLogger } from "./logger.js"; +import type { WebSearchProviderPlugin } from "./types.js"; + +const log = createSubsystemLogger("plugins"); + +const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ + "web-search-brave", + "web-search-gemini", + "web-search-grok", + "moonshot", + "web-search-perplexity", +] as const; + +function withBundledWebSearchAllowlistCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const allow = config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + allow: [...allowSet], + }, + }; +} + +function withBundledWebSearchEnablementCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const existingEntries = config?.plugins?.entries ?? {}; + let changed = false; + const nextEntries: Record = { ...existingEntries }; + + for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (existingEntries[pluginId] !== undefined) { + continue; + } + nextEntries[pluginId] = { enabled: true }; + changed = true; + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + entries: { + ...existingEntries, + ...nextEntries, + }, + }, + }; +} + +export function resolvePluginWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): WebSearchProviderPlugin[] { + const allowlistCompat = params.bundledAllowlistCompat + ? withBundledWebSearchAllowlistCompat(params.config) + : params.config; + const config = withBundledWebSearchEnablementCompat(allowlistCompat); + const registry = loadOpenClawPlugins({ + config, + workspaceDir: params.workspaceDir, + env: params.env, + logger: createPluginLoaderLogger(log), + activate: false, + cache: false, + onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS], + }); + + return registry.webSearchProviders + .map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })) + .toSorted((a, b) => { + const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + return a.id.localeCompare(b.id); + }); +} diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 883aac6bd02..71b346cc462 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; @@ -9,53 +10,28 @@ import { type ResolverContext, type SecretDefaults, } from "./runtime-shared.js"; +import type { + RuntimeWebDiagnostic, + RuntimeWebDiagnosticCode, + RuntimeWebFetchFirecrawlMetadata, + RuntimeWebSearchMetadata, + RuntimeWebToolsMetadata, +} from "./runtime-web-tools.types.js"; -const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; -type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; +type WebSearchProvider = string; type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret -type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; - -export type RuntimeWebDiagnosticCode = - | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" - | "WEB_SEARCH_AUTODETECT_SELECTED" - | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" - | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" - | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" - | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; - -export type RuntimeWebDiagnostic = { - code: RuntimeWebDiagnosticCode; - message: string; - path?: string; -}; - -export type RuntimeWebSearchMetadata = { - providerConfigured?: WebSearchProvider; - providerSource: RuntimeWebProviderSource; - selectedProvider?: WebSearchProvider; - selectedProviderKeySource?: SecretResolutionSource; - perplexityTransport?: "search_api" | "chat_completions"; - diagnostics: RuntimeWebDiagnostic[]; -}; - -export type RuntimeWebFetchFirecrawlMetadata = { - active: boolean; - apiKeySource: SecretResolutionSource; - diagnostics: RuntimeWebDiagnostic[]; -}; - -export type RuntimeWebToolsMetadata = { - search: RuntimeWebSearchMetadata; - fetch: { - firecrawl: RuntimeWebFetchFirecrawlMetadata; - }; - diagnostics: RuntimeWebDiagnostic[]; +export type { + RuntimeWebDiagnostic, + RuntimeWebDiagnosticCode, + RuntimeWebFetchFirecrawlMetadata, + RuntimeWebSearchMetadata, + RuntimeWebToolsMetadata, }; type FetchConfig = NonNullable["web"] extends infer Web @@ -77,18 +53,15 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function normalizeProvider(value: unknown): WebSearchProvider | undefined { +function normalizeProvider( + value: unknown, + providers: ReturnType, +): WebSearchProvider | undefined { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); - if ( - normalized === "brave" || - normalized === "gemini" || - normalized === "grok" || - normalized === "kimi" || - normalized === "perplexity" - ) { + if (providers.some((provider) => provider.id === normalized)) { return normalized; } return undefined; @@ -293,16 +266,18 @@ function setResolvedWebSearchApiKey(params: { resolvedConfig: OpenClawConfig; provider: WebSearchProvider; value: string; + sourceConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; }): void { const tools = ensureObject(params.resolvedConfig as Record, "tools"); const web = ensureObject(tools, "web"); const search = ensureObject(web, "search"); - if (params.provider === "brave") { - search.apiKey = params.value; - return; - } - const providerConfig = ensureObject(search, params.provider); - providerConfig.apiKey = params.value; + const provider = resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.env, + bundledAllowlistCompat: true, + }).find((entry) => entry.id === params.provider); + provider?.setCredentialValue(search, params.value); } function setResolvedFirecrawlApiKey(params: { @@ -316,34 +291,8 @@ function setResolvedFirecrawlApiKey(params: { firecrawl.apiKey = params.value; } -function envVarsForProvider(provider: WebSearchProvider): string[] { - if (provider === "brave") { - return ["BRAVE_API_KEY"]; - } - if (provider === "gemini") { - return ["GEMINI_API_KEY"]; - } - if (provider === "grok") { - return ["XAI_API_KEY"]; - } - if (provider === "kimi") { - return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]; - } - return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]; -} - -function resolveProviderKeyValue( - search: Record, - provider: WebSearchProvider, -): unknown { - if (provider === "brave") { - return search.apiKey; - } - const scoped = search[provider]; - if (!isRecord(scoped)) { - return undefined; - } - return scoped.apiKey; +function keyPathForProvider(provider: WebSearchProvider): string { + return provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; } function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean { @@ -366,6 +315,11 @@ export async function resolveRuntimeWebTools(params: { const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; const web = isRecord(tools?.web) ? tools.web : undefined; const search = isRecord(web?.search) ? web.search : undefined; + const providers = resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.context.env, + bundledAllowlistCompat: true, + }); const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", @@ -375,7 +329,7 @@ export async function resolveRuntimeWebTools(params: { const searchEnabled = search?.enabled !== false; const rawProvider = typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; - const configuredProvider = normalizeProvider(rawProvider); + const configuredProvider = normalizeProvider(rawProvider, providers); if (rawProvider && !configuredProvider) { const diagnostic: RuntimeWebDiagnostic = { @@ -398,7 +352,9 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search) { - const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS]; + const candidates = configuredProvider + ? providers.filter((provider) => provider.id === configuredProvider) + : providers; const unresolvedWithoutFallback: Array<{ provider: WebSearchProvider; path: string; @@ -409,16 +365,15 @@ export async function resolveRuntimeWebTools(params: { let selectedResolution: SecretResolutionResult | undefined; for (const provider of candidates) { - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); const resolution = await resolveSecretInputWithEnvFallback({ sourceConfig: params.sourceConfig, context: params.context, defaults, value, path, - envVars: envVarsForProvider(provider), + envVars: provider.envVars, }); if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { @@ -440,32 +395,36 @@ export async function resolveRuntimeWebTools(params: { if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { unresolvedWithoutFallback.push({ - provider, + provider: provider.id, path, reason: resolution.unresolvedRefReason, }); } if (configuredProvider) { - selectedProvider = provider; + selectedProvider = provider.id; selectedResolution = resolution; if (resolution.value) { setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider, + provider: provider.id, value: resolution.value, + sourceConfig: params.sourceConfig, + env: params.context.env, }); } break; } if (resolution.value) { - selectedProvider = provider; + selectedProvider = provider.id; selectedResolution = resolution; setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider, + provider: provider.id, value: resolution.value, + sourceConfig: params.sourceConfig, + env: params.context.env, }); break; } @@ -526,13 +485,12 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { - for (const provider of WEB_SEARCH_PROVIDERS) { - if (provider === searchMetadata.selectedProvider) { + for (const provider of providers) { + if (provider.id === searchMetadata.selectedProvider) { continue; } - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } @@ -543,10 +501,9 @@ export async function resolveRuntimeWebTools(params: { }); } } else if (search && !searchEnabled) { - for (const provider of WEB_SEARCH_PROVIDERS) { - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + for (const provider of providers) { + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } @@ -559,13 +516,12 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search && configuredProvider) { - for (const provider of WEB_SEARCH_PROVIDERS) { - if (provider === configuredProvider) { + for (const provider of providers) { + if (provider.id === configuredProvider) { continue; } - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } diff --git a/src/secrets/runtime-web-tools.types.ts b/src/secrets/runtime-web-tools.types.ts new file mode 100644 index 00000000000..fe5fdb24cd0 --- /dev/null +++ b/src/secrets/runtime-web-tools.types.ts @@ -0,0 +1,36 @@ +export type RuntimeWebDiagnosticCode = + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_AUTODETECT_SELECTED" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; + +export type RuntimeWebDiagnostic = { + code: RuntimeWebDiagnosticCode; + message: string; + path?: string; +}; + +export type RuntimeWebSearchMetadata = { + providerConfigured?: string; + providerSource: "configured" | "auto-detect" | "none"; + selectedProvider?: string; + selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing"; + perplexityTransport?: "search_api" | "chat_completions"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebFetchFirecrawlMetadata = { + active: boolean; + apiKeySource: "config" | "secretRef" | "env" | "missing"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebToolsMetadata = { + search: RuntimeWebSearchMetadata; + fetch: { + firecrawl: RuntimeWebFetchFirecrawlMetadata; + }; + diagnostics: RuntimeWebDiagnostic[]; +}; From 3aa5f2703c5e299fad13f8303547e9b96e1b4f14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:40:42 +0000 Subject: [PATCH 005/331] fix(web-search): restore build after plugin rebase --- src/agents/tools/web-search-core.ts | 13 ++++++++++--- src/plugins/web-search-providers.ts | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agents/tools/web-search-core.ts b/src/agents/tools/web-search-core.ts index 48d2d620b49..bebc659c306 100644 --- a/src/agents/tools/web-search-core.ts +++ b/src/agents/tools/web-search-core.ts @@ -23,6 +23,7 @@ import { } from "./web-shared.js"; const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +type SearchProvider = (typeof SEARCH_PROVIDERS)[number]; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -614,6 +615,10 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { }; } +function isSearchProvider(value: string): value is SearchProvider { + return SEARCH_PROVIDERS.includes(value as SearchProvider); +} + function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { const raw = search && "provider" in search && typeof search.provider === "string" @@ -1911,10 +1916,12 @@ export function createWebSearchTool(options?: { return null; } + const runtimeProviderCandidate = + options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured; const provider = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); + runtimeProviderCandidate && isSearchProvider(runtimeProviderCandidate) + ? runtimeProviderCandidate + : resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); const perplexitySchemaTransportHint = options?.runtimeWebSearch?.perplexityTransport ?? diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 1c5b7fb15e6..00b424977da 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,3 +1,4 @@ +import type { PluginEntryConfig } from "../config/types.plugins.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -48,7 +49,7 @@ function withBundledWebSearchEnablementCompat( ): PluginLoadOptions["config"] { const existingEntries = config?.plugins?.entries ?? {}; let changed = false; - const nextEntries: Record = { ...existingEntries }; + const nextEntries: Record = { ...existingEntries }; for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { if (existingEntries[pluginId] !== undefined) { From 579d0ebe2ba01e2c3b488d764dabf91676df4a08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:06:12 +0000 Subject: [PATCH 006/331] refactor(web-search): move providers into company plugins --- .../{web-search-brave => brave}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../{web-search-grok => brave}/package.json | 4 ++-- .../{web-search-gemini => google}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../{web-search-brave => google}/package.json | 4 ++-- .../index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../package.json | 4 ++-- extensions/{web-search-grok => xai}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../package.json | 4 ++-- src/plugins/web-search-providers.test.ts | 22 ++++++++----------- src/plugins/web-search-providers.ts | 8 +++---- 14 files changed, 45 insertions(+), 49 deletions(-) rename extensions/{web-search-brave => brave}/index.ts (83%) rename extensions/{web-search-grok => brave}/openclaw.plugin.json (79%) rename extensions/{web-search-grok => brave}/package.json (57%) rename extensions/{web-search-gemini => google}/index.ts (84%) rename extensions/{web-search-brave => google}/openclaw.plugin.json (79%) rename extensions/{web-search-brave => google}/package.json (56%) rename extensions/{web-search-perplexity => perplexity}/index.ts (83%) rename extensions/{web-search-gemini => perplexity}/openclaw.plugin.json (78%) rename extensions/{web-search-gemini => perplexity}/package.json (56%) rename extensions/{web-search-grok => xai}/index.ts (84%) rename extensions/{web-search-perplexity => xai}/openclaw.plugin.json (76%) rename extensions/{web-search-perplexity => xai}/package.json (54%) diff --git a/extensions/web-search-brave/index.ts b/extensions/brave/index.ts similarity index 83% rename from extensions/web-search-brave/index.ts rename to extensions/brave/index.ts index 7345e10f011..1150dec5d80 100644 --- a/extensions/web-search-brave/index.ts +++ b/extensions/brave/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const braveSearchPlugin = { - id: "web-search-brave", - name: "Web Search Brave Provider", - description: "Bundled Brave provider for the web_search tool", +const bravePlugin = { + id: "brave", + name: "Brave Plugin", + description: "Bundled Brave plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -29,4 +29,4 @@ const braveSearchPlugin = { }, }; -export default braveSearchPlugin; +export default bravePlugin; diff --git a/extensions/web-search-grok/openclaw.plugin.json b/extensions/brave/openclaw.plugin.json similarity index 79% rename from extensions/web-search-grok/openclaw.plugin.json rename to extensions/brave/openclaw.plugin.json index ccc55644521..404382996d7 100644 --- a/extensions/web-search-grok/openclaw.plugin.json +++ b/extensions/brave/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-grok", + "id": "brave", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-grok/package.json b/extensions/brave/package.json similarity index 57% rename from extensions/web-search-grok/package.json rename to extensions/brave/package.json index 9baa872250e..6756c616e9a 100644 --- a/extensions/web-search-grok/package.json +++ b/extensions/brave/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-grok", + "name": "@openclaw/brave-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Grok web search provider plugin", + "description": "OpenClaw Brave plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-gemini/index.ts b/extensions/google/index.ts similarity index 84% rename from extensions/web-search-gemini/index.ts rename to extensions/google/index.ts index 998fbd69a04..5691137070b 100644 --- a/extensions/web-search-gemini/index.ts +++ b/extensions/google/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const geminiSearchPlugin = { - id: "web-search-gemini", - name: "Web Search Gemini Provider", - description: "Bundled Gemini provider for the web_search tool", +const googlePlugin = { + id: "google", + name: "Google Plugin", + description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const geminiSearchPlugin = { }, }; -export default geminiSearchPlugin; +export default googlePlugin; diff --git a/extensions/web-search-brave/openclaw.plugin.json b/extensions/google/openclaw.plugin.json similarity index 79% rename from extensions/web-search-brave/openclaw.plugin.json rename to extensions/google/openclaw.plugin.json index 606091921e9..40594e2f3f9 100644 --- a/extensions/web-search-brave/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-brave", + "id": "google", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-brave/package.json b/extensions/google/package.json similarity index 56% rename from extensions/web-search-brave/package.json rename to extensions/google/package.json index c8807445a28..64c04bc67da 100644 --- a/extensions/web-search-brave/package.json +++ b/extensions/google/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-brave", + "name": "@openclaw/google-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Brave web search provider plugin", + "description": "OpenClaw Google plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-perplexity/index.ts b/extensions/perplexity/index.ts similarity index 83% rename from extensions/web-search-perplexity/index.ts rename to extensions/perplexity/index.ts index 83f778aba96..513c70d131d 100644 --- a/extensions/web-search-perplexity/index.ts +++ b/extensions/perplexity/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const perplexitySearchPlugin = { - id: "web-search-perplexity", - name: "Web Search Perplexity Provider", - description: "Bundled Perplexity provider for the web_search tool", +const perplexityPlugin = { + id: "perplexity", + name: "Perplexity Plugin", + description: "Bundled Perplexity plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const perplexitySearchPlugin = { }, }; -export default perplexitySearchPlugin; +export default perplexityPlugin; diff --git a/extensions/web-search-gemini/openclaw.plugin.json b/extensions/perplexity/openclaw.plugin.json similarity index 78% rename from extensions/web-search-gemini/openclaw.plugin.json rename to extensions/perplexity/openclaw.plugin.json index a2baa4b274d..6b976506b65 100644 --- a/extensions/web-search-gemini/openclaw.plugin.json +++ b/extensions/perplexity/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-gemini", + "id": "perplexity", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-gemini/package.json b/extensions/perplexity/package.json similarity index 56% rename from extensions/web-search-gemini/package.json rename to extensions/perplexity/package.json index 1a595b2b060..2a6321ba56c 100644 --- a/extensions/web-search-gemini/package.json +++ b/extensions/perplexity/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-gemini", + "name": "@openclaw/perplexity-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Gemini web search provider plugin", + "description": "OpenClaw Perplexity plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-grok/index.ts b/extensions/xai/index.ts similarity index 84% rename from extensions/web-search-grok/index.ts rename to extensions/xai/index.ts index 726879ed43b..dca48a1e466 100644 --- a/extensions/web-search-grok/index.ts +++ b/extensions/xai/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const grokSearchPlugin = { - id: "web-search-grok", - name: "Web Search Grok Provider", - description: "Bundled Grok provider for the web_search tool", +const xaiPlugin = { + id: "xai", + name: "xAI Plugin", + description: "Bundled xAI plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const grokSearchPlugin = { }, }; -export default grokSearchPlugin; +export default xaiPlugin; diff --git a/extensions/web-search-perplexity/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json similarity index 76% rename from extensions/web-search-perplexity/openclaw.plugin.json rename to extensions/xai/openclaw.plugin.json index fc9907a3dc2..507265a4ef3 100644 --- a/extensions/web-search-perplexity/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-perplexity", + "id": "xai", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-perplexity/package.json b/extensions/xai/package.json similarity index 54% rename from extensions/web-search-perplexity/package.json rename to extensions/xai/package.json index d3724a3b2e3..be904ee3c89 100644 --- a/extensions/web-search-perplexity/package.json +++ b/extensions/xai/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-perplexity", + "name": "@openclaw/xai-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Perplexity web search provider plugin", + "description": "OpenClaw xAI plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index af794d075c9..2e7b79c64d2 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -13,7 +13,7 @@ describe("resolvePluginWebSearchProviders", () => { loadOpenClawPluginsMock.mockReturnValue({ webSearchProviders: [ { - pluginId: "web-search-gemini", + pluginId: "google", provider: { id: "gemini", label: "Gemini", @@ -25,7 +25,7 @@ describe("resolvePluginWebSearchProviders", () => { }, }, { - pluginId: "web-search-brave", + pluginId: "brave", provider: { id: "brave", label: "Brave", @@ -71,11 +71,7 @@ describe("resolvePluginWebSearchProviders", () => { expect.objectContaining({ config: expect.objectContaining({ plugins: expect.objectContaining({ - allow: expect.arrayContaining([ - "openrouter", - "web-search-brave", - "web-search-perplexity", - ]), + allow: expect.arrayContaining(["openrouter", "brave", "perplexity"]), }), }), }), @@ -99,11 +95,11 @@ describe("resolvePluginWebSearchProviders", () => { plugins: expect.objectContaining({ entries: expect.objectContaining({ openrouter: { enabled: true }, - "web-search-brave": { enabled: true }, - "web-search-gemini": { enabled: true }, - "web-search-grok": { enabled: true }, + brave: { enabled: true }, + google: { enabled: true }, moonshot: { enabled: true }, - "web-search-perplexity": { enabled: true }, + perplexity: { enabled: true }, + xai: { enabled: true }, }), }), }), @@ -116,7 +112,7 @@ describe("resolvePluginWebSearchProviders", () => { config: { plugins: { entries: { - "web-search-perplexity": { enabled: false }, + perplexity: { enabled: false }, }, }, }, @@ -127,7 +123,7 @@ describe("resolvePluginWebSearchProviders", () => { config: expect.objectContaining({ plugins: expect.objectContaining({ entries: expect.objectContaining({ - "web-search-perplexity": { enabled: false }, + perplexity: { enabled: false }, }), }), }), diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 00b424977da..8120be0113c 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -7,11 +7,11 @@ import type { WebSearchProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ - "web-search-brave", - "web-search-gemini", - "web-search-grok", + "brave", + "google", "moonshot", - "web-search-perplexity", + "perplexity", + "xai", ] as const; function withBundledWebSearchAllowlistCompat( From 7a93f7d9dfe63a40793613ad6290fc3f53d593a7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:08:09 -0700 Subject: [PATCH 007/331] WhatsApp: lazy-load setup wizard surface --- extensions/whatsapp/src/channel.runtime.ts | 1 + extensions/whatsapp/src/channel.ts | 53 +++++++++++++++++++++- extensions/whatsapp/src/setup-core.ts | 52 +++++++++++++++++++++ extensions/whatsapp/src/setup-surface.ts | 50 +------------------- 4 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 extensions/whatsapp/src/channel.runtime.ts create mode 100644 extensions/whatsapp/src/setup-core.ts diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts new file mode 100644 index 00000000000..ff67d34ee10 --- /dev/null +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -0,0 +1 @@ +export { whatsappSetupWizard } from "./setup-surface.js"; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e240824c743..63c01bca05c 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -33,11 +33,60 @@ import { } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; import { getWhatsAppRuntime } from "./runtime.js"; -import { whatsappSetupAdapter, whatsappSetupWizard } from "./setup-surface.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + }), + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; + export const whatsappPlugin: ChannelPlugin = { id: "whatsapp", meta: { @@ -47,7 +96,7 @@ export const whatsappPlugin: ChannelPlugin = { forceAccountBinding: true, preferSessionLookupForAnnounceTarget: true, }, - setupWizard: whatsappSetupWizard, + setupWizard: whatsappSetupWizardProxy, agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts new file mode 100644 index 00000000000..2b243743076 --- /dev/null +++ b/extensions/whatsapp/src/setup-core.ts @@ -0,0 +1,52 @@ +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "whatsapp" as const; + +export const whatsappSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + alwaysUseAccounts: true, + }); + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + alwaysUseAccounts: true, + }); + const entry = { + ...next.channels?.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: entry, + }, + }, + }, + }; + }, +}; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 180f84a3fbf..e0e9fa3191b 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -5,12 +5,7 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; @@ -19,6 +14,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/ses import { formatDocsLink } from "../../../src/terminal/links.js"; import { normalizeE164, pathExists } from "../../../src/utils.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; const channel = "whatsapp" as const; @@ -247,50 +243,6 @@ async function promptWhatsAppDmAccess(params: { return setWhatsAppAllowFrom(next, parsed.entries); } -export const whatsappSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, -}; - export const whatsappSetupWizard: ChannelSetupWizard = { channel, status: { From b8dbc12560e8b11d60da888bf2b632af37f83fa4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:10:22 +0000 Subject: [PATCH 008/331] fix: align channel adapters with plugin sdk --- extensions/feishu/src/channel.ts | 5 +++-- extensions/matrix/src/channel.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 17f3e5cc580..ecfd27194b7 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -65,10 +65,11 @@ const feishuOnboarding = { }, }, }), - promptAllowFrom: async (cfg, prompter) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({ cfg, prompter, + accountId, }), }, disable: (cfg) => ({ diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index c9f95d3d671..0522590356a 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -382,10 +382,10 @@ export const matrixPlugin: ChannelPlugin = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText(params), + sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params), sendMedia: async (params) => - (await loadMatrixChannelRuntime()).matrixOutbound.sendMedia(params), - sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll(params), + (await loadMatrixChannelRuntime()).matrixOutbound.sendMedia!(params), + sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll!(params), }, status: { defaultRuntime: { From d56559bad7dfb60a2a83f5e00c23a6185883b910 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:15:31 +0000 Subject: [PATCH 009/331] fix: repair node24 ci type drift --- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/test-utils/plugin-api.ts | 1 + extensions/whatsapp/src/channel.ts | 4 +-- src/agents/tools/web-search-plugin-factory.ts | 13 +++++-- src/auto-reply/reply/route-reply.test.ts | 1 + src/commands/configure.wizard.ts | 11 +++--- src/commands/onboard-search.ts | 36 +++++++++++++------ .../onboarding/plugin-install.test.ts | 1 + src/config/test-helpers.ts | 5 ++- src/gateway/server-plugins.test.ts | 1 + ...server.agent.gateway-server-agent.mocks.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + src/test-utils/channel-plugins.ts | 1 + 13 files changed, 58 insertions(+), 19 deletions(-) diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index bde3767845c..21d090846b0 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -44,6 +44,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerHook() {}, registerHttpRoute() {}, diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index c2eaeced2e5..5c621700602 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -15,6 +15,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerCommand() {}, registerContextEngine() {}, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 63c01bca05c..d73c951a054 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -58,12 +58,12 @@ const whatsappSetupWizardProxy = { cfg, }), resolveStatusLines: async ({ cfg, configured }) => - await ( + (await ( await loadWhatsAppChannelRuntime() ).whatsappSetupWizard.status.resolveStatusLines?.({ cfg, configured, - }), + })) ?? [], }, resolveShouldPromptAccountIds: (params) => (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, diff --git a/src/agents/tools/web-search-plugin-factory.ts b/src/agents/tools/web-search-plugin-factory.ts index 8022b2e354d..ab80702a6ed 100644 --- a/src/agents/tools/web-search-plugin-factory.ts +++ b/src/agents/tools/web-search-plugin-factory.ts @@ -2,6 +2,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { WebSearchProviderPlugin } from "../../plugins/types.js"; import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js"; +type ConfiguredWebSearchProvider = NonNullable< + NonNullable["web"]>["search"] +>["provider"]; + function cloneWithDescriptors(value: T | undefined): T { const next = Object.create(Object.getPrototypeOf(value ?? {})) as T; if (value) { @@ -10,7 +14,10 @@ function cloneWithDescriptors(value: T | undefined): T { return next; } -function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig { +function withForcedProvider( + config: OpenClawConfig | undefined, + provider: ConfiguredWebSearchProvider, +): OpenClawConfig { const next = cloneWithDescriptors(config ?? {}); const tools = cloneWithDescriptors(next.tools ?? {}); const web = cloneWithDescriptors(tools.web ?? {}); @@ -25,7 +32,9 @@ function withForcedProvider(config: OpenClawConfig | undefined, provider: string } export function createPluginBackedWebSearchProvider( - provider: Omit, + provider: Omit & { + id: ConfiguredWebSearchProvider; + }, ): WebSearchProviderPlugin { return { ...provider, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ed507607c83..0a717f9bfc7 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -88,6 +88,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => enabled: true, })), providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80af67043ab..6e5f0203be0 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -174,6 +174,10 @@ async function promptWebToolsConfig( hasKeyInEnv, } = await import("./onboard-search.js"); type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; + const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value; + if (!defaultProvider) { + throw new Error("No web search providers are registered."); + } const hasKeyForProvider = (provider: string): boolean => { const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); @@ -183,14 +187,13 @@ async function promptWebToolsConfig( return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); }; - const existingProvider: string = (() => { + const existingProvider: SP = (() => { const stored = existingSearch?.provider; if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { - return stored; + return stored as SP; } return ( - SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? - SEARCH_PROVIDER_OPTIONS[0].value + SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider ); })(); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index d1281fe3fc7..af5f3cd9a8f 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -11,7 +11,21 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; -export type SearchProvider = string; +export type SearchProvider = NonNullable< + NonNullable["web"]>["search"]>["provider"] +>; + +const SEARCH_PROVIDER_IDS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; + +function isSearchProvider(value: string): value is SearchProvider { + return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value); +} + +function hasSearchProviderId( + provider: T, +): provider is T & { id: SearchProvider } { + return isSearchProvider(provider.id); +} type SearchProviderEntry = { value: SearchProvider; @@ -25,14 +39,16 @@ type SearchProviderEntry = { export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true, - }).map((provider) => ({ - value: provider.id, - label: provider.label, - hint: provider.hint, - envKeys: provider.envVars, - placeholder: provider.placeholder, - signupUrl: provider.signupUrl, - })); + }) + .filter(hasSearchProviderId) + .map((provider) => ({ + value: provider.id, + label: provider.label, + hint: provider.hint, + envKeys: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + })); export function hasKeyInEnv(entry: SearchProviderEntry): boolean { return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); @@ -178,7 +194,7 @@ export async function setupSearch( return SEARCH_PROVIDER_OPTIONS[0].value; })(); - type PickerValue = string; + type PickerValue = SearchProvider | "__skip__"; const choice = await prompter.select({ message: "Search provider", options: [ diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index d2c55d330c7..1cd9e530b86 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -335,6 +335,7 @@ describe("ensureOnboardingPluginInstalled", () => { hookNames: [], channelIds: [], providerIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 69e7745a85b..5809a37da2d 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "./config.js"; export async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-config-" }); @@ -53,7 +54,9 @@ export async function withEnvOverride( } export function buildWebSearchProviderConfig(params: { - provider: string; + provider: NonNullable< + NonNullable["web"]>["search"]>["provider"] + >; enabled?: boolean; providerConfig?: Record; }): Record { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 560392499c1..2db21cccde1 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -29,6 +29,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ channelSetups: [], commands: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index 0e1f779ef4f..acf507dbde2 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -11,6 +11,7 @@ export const registryState: { registry: PluginRegistry } = { channels: [], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpHandlers: [], httpRoutes: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 17868ae0bca..59ad8a9cedc 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -146,6 +146,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index ebec4f2c747..2af1191feba 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -25,6 +25,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl enabled: true, })), providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], From bc5054ce686cabf9a61fcd17d636a7679ba7e921 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:13:17 +0000 Subject: [PATCH 010/331] refactor(google): merge gemini auth into google plugin --- docs/concepts/model-providers.md | 15 +- docs/help/faq.md | 2 +- docs/tools/plugin.md | 6 +- extensions/google-gemini-cli-auth/README.md | 41 ----- extensions/google-gemini-cli-auth/index.ts | 158 ------------------ .../openclaw.plugin.json | 9 - .../google-gemini-cli-auth/package.json | 12 -- .../gemini-cli-provider.test.ts} | 30 +++- extensions/google/gemini-cli-provider.ts | 149 +++++++++++++++++ extensions/google/index.ts | 2 + .../oauth.test.ts | 5 +- .../oauth.ts | 3 +- extensions/google/openclaw.plugin.json | 1 + package.json | 4 - scripts/check-no-raw-channel-fetch.mjs | 5 - scripts/check-plugin-sdk-exports.mjs | 1 - scripts/release-check.ts | 2 - scripts/write-plugin-sdk-entry-dts.ts | 1 - ...uth-choice.apply.google-gemini-cli.test.ts | 2 +- .../auth-choice.apply.google-gemini-cli.ts | 2 +- src/config/plugin-auto-enable.test.ts | 2 +- src/config/plugin-auto-enable.ts | 2 +- src/plugin-sdk/google-gemini-cli-auth.ts | 15 -- src/plugin-sdk/index.test.ts | 1 - src/plugin-sdk/subpaths.test.ts | 4 - src/plugins/enable.test.ts | 12 +- src/plugins/providers.ts | 2 +- tsconfig.plugin-sdk.dts.json | 1 - tsdown.config.ts | 1 - vitest.config.ts | 1 - 30 files changed, 200 insertions(+), 291 deletions(-) delete mode 100644 extensions/google-gemini-cli-auth/README.md delete mode 100644 extensions/google-gemini-cli-auth/index.ts delete mode 100644 extensions/google-gemini-cli-auth/openclaw.plugin.json delete mode 100644 extensions/google-gemini-cli-auth/package.json rename extensions/{google-gemini-cli-auth/index.test.ts => google/gemini-cli-provider.test.ts} (79%) create mode 100644 extensions/google/gemini-cli-provider.ts rename extensions/{google-gemini-cli-auth => google}/oauth.test.ts (99%) rename extensions/{google-gemini-cli-auth => google}/oauth.ts (99%) delete mode 100644 src/plugin-sdk/google-gemini-cli-auth.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 3a29c373c1d..d20b5055763 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -176,16 +176,13 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview` - CLI: `openclaw onboard --auth-choice gemini-api-key` -### Google Vertex, Antigravity, and Gemini CLI +### Google Vertex and Gemini CLI -- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli` -- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows -- Caution: Antigravity and Gemini CLI OAuth in OpenClaw are unofficial integrations. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. -- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default). - - Enable: `openclaw plugins enable google-antigravity-auth` - - Login: `openclaw models auth login --provider google-antigravity --set-default` -- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default). - - Enable: `openclaw plugins enable google-gemini-cli-auth` +- Providers: `google-vertex`, `google-gemini-cli` +- Auth: Vertex uses gcloud ADC; Gemini CLI uses its OAuth flow +- Caution: Gemini CLI OAuth in OpenClaw is an unofficial integration. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. +- Gemini CLI OAuth is shipped as part of the bundled `google` plugin. + - Enable: `openclaw plugins enable google` - Login: `openclaw models auth login --provider google-gemini-cli --set-default` - Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores tokens in auth profiles on the gateway host. diff --git a/docs/help/faq.md b/docs/help/faq.md index 236097634c1..c402230aaa3 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -783,7 +783,7 @@ Gemini CLI uses a **plugin auth flow**, not a client id or secret in `openclaw.j Steps: -1. Enable the plugin: `openclaw plugins enable google-gemini-cli-auth` +1. Enable the plugin: `openclaw plugins enable google` 2. Login: `openclaw models auth login --provider google-gemini-cli --set-default` This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8aa7beefa42..59752ddf253 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -167,8 +167,7 @@ Important trust note: - Anthropic provider runtime — bundled as `anthropic` (enabled by default) - BytePlus provider catalog — bundled as `byteplus` (enabled by default) - Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default) -- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) -- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) +- Google web search + Gemini CLI OAuth — bundled as `google` (web search auto-loads it; provider auth stays opt-in) - GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default) - Hugging Face provider catalog — bundled as `huggingface` (enabled by default) - Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) @@ -521,8 +520,7 @@ authoring plugins: `openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`, `openclaw/plugin-sdk/copilot-proxy`, `openclaw/plugin-sdk/device-pair`, `openclaw/plugin-sdk/diagnostics-otel`, `openclaw/plugin-sdk/diffs`, - `openclaw/plugin-sdk/feishu`, - `openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`, + `openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`, `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`, `openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md deleted file mode 100644 index bbca53ba1ce..00000000000 --- a/extensions/google-gemini-cli-auth/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Google Gemini CLI Auth (OpenClaw plugin) - -OAuth provider plugin for **Gemini CLI** (Google Code Assist). - -## Account safety caution - -- This plugin is an unofficial integration and is not endorsed by Google. -- Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients. -- Use caution, review the applicable Google terms, and avoid using a mission-critical account. - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-gemini-cli-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-gemini-cli --set-default -``` - -## Requirements - -Requires the Gemini CLI to be installed (credentials are extracted automatically): - -```bash -brew install gemini-cli -# or: npm install -g @google/gemini-cli -``` - -## Env vars (optional) - -Override auto-detected credentials with: - -- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` -- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts deleted file mode 100644 index 290cc19598f..00000000000 --- a/extensions/google-gemini-cli-auth/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type ProviderFetchUsageSnapshotContext, - type OpenClawPluginApi, - type ProviderAuthContext, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/google-gemini-cli-auth"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { loginGeminiCliOAuth } from "./oauth.js"; - -const PROVIDER_ID = "google-gemini-cli"; -const PROVIDER_LABEL = "Gemini CLI OAuth"; -const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; -const ENV_VARS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -]; - -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - reasoning: true, - } as ProviderRuntimeModel); - } - return undefined; -} - -function parseGoogleUsageToken(apiKey: string): string { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (typeof parsed?.token === "string") { - return parsed.token; - } - } catch { - // ignore - } - return apiKey; -} - -async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { - return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); -} - -function resolveGeminiCliForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmed = ctx.modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - modelId: trimmed, - templateIds, - ctx, - }); -} - -const geminiCliPlugin = { - id: "google-gemini-cli-auth", - name: "Google Gemini CLI Auth", - description: "OAuth flow for Gemini CLI (Google Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: PROVIDER_LABEL, - docsPath: "/providers/models", - aliases: ["gemini-cli"], - envVars: ENV_VARS, - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); - try { - const result = await loginGeminiCliOAuth({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - log: (msg) => ctx.runtime.log(msg), - note: ctx.prompter.note, - prompt: async (message) => String(await ctx.prompter.text({ message })), - progress: spin, - }); - - spin.stop("Gemini CLI OAuth complete"); - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], - }); - } catch (err) { - spin.stop("Gemini CLI OAuth failed"); - await ctx.prompter.note( - "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", - "OAuth help", - ); - throw err; - } - }, - }, - ], - resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), - resolveUsageAuth: async (ctx) => { - const auth = await ctx.resolveOAuthToken(); - if (!auth) { - return null; - } - return { - ...auth, - token: parseGoogleUsageToken(auth.token), - }; - }, - fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), - }); - }, -}; - -export default geminiCliPlugin; diff --git a/extensions/google-gemini-cli-auth/openclaw.plugin.json b/extensions/google-gemini-cli-auth/openclaw.plugin.json deleted file mode 100644 index c8f632da0c8..00000000000 --- a/extensions/google-gemini-cli-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-gemini-cli-auth", - "providers": ["google-gemini-cli"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json deleted file mode 100644 index 61ae5be803c..00000000000 --- a/extensions/google-gemini-cli-auth/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw Gemini CLI OAuth provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/index.test.ts b/extensions/google/gemini-cli-provider.test.ts similarity index 79% rename from extensions/google-gemini-cli-auth/index.test.ts rename to extensions/google/gemini-cli-provider.test.ts index d0542e3473c..ad5969c7c4d 100644 --- a/extensions/google-gemini-cli-auth/index.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -4,24 +4,38 @@ import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; -import geminiCliPlugin from "./index.js"; +import googlePlugin from "./index.js"; -function registerProvider(): ProviderPlugin { +function registerGooglePlugin(): { + provider: ProviderPlugin; + webSearchProviderRegistered: boolean; +} { let provider: ProviderPlugin | undefined; - geminiCliPlugin.register({ + let webSearchProviderRegistered = false; + googlePlugin.register({ registerProvider(nextProvider: ProviderPlugin) { provider = nextProvider; }, + registerWebSearchProvider() { + webSearchProviderRegistered = true; + }, } as never); if (!provider) { throw new Error("provider registration missing"); } - return provider; + return { provider, webSearchProviderRegistered }; } -describe("google-gemini-cli-auth plugin", () => { +describe("google plugin", () => { + it("registers both Gemini CLI auth and Gemini web search", () => { + const result = registerGooglePlugin(); + + expect(result.provider.id).toBe("google-gemini-cli"); + expect(result.webSearchProviderRegistered).toBe(true); + }); + it("owns gemini 3.1 forward-compat resolution", () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); const model = provider.resolveDynamicModel?.({ provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", @@ -52,7 +66,7 @@ describe("google-gemini-cli-auth plugin", () => { }); it("owns usage-token parsing", async () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -71,7 +85,7 @@ describe("google-gemini-cli-auth plugin", () => { }); it("owns usage snapshot fetching", async () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { return makeResponse(200, { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts new file mode 100644 index 00000000000..b4bb58f7d80 --- /dev/null +++ b/extensions/google/gemini-cli-provider.ts @@ -0,0 +1,149 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; +import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; +import type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderFetchUsageSnapshotContext, + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; +import { loginGeminiCliOAuth } from "./oauth.js"; + +const PROVIDER_ID = "google-gemini-cli"; +const PROVIDER_LABEL = "Gemini CLI OAuth"; +const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; +const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; +const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; +const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; +const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; +const ENV_VARS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; + +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + return undefined; +} + +function parseGoogleUsageToken(apiKey: string): string { + try { + const parsed = JSON.parse(apiKey) as { token?: unknown }; + if (typeof parsed?.token === "string") { + return parsed.token; + } + } catch { + // ignore + } + return apiKey; +} + +async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { + return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); +} + +function resolveGeminiCliForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmed = ctx.modelId.trim(); + const lower = trimmed.toLowerCase(); + + let templateIds: readonly string[]; + if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { + templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; + } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { + templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; + } else { + return undefined; + } + + return cloneFirstTemplateModel({ + modelId: trimmed, + templateIds, + ctx, + }); +} + +export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: ENV_VARS, + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); + try { + const result = await loginGeminiCliOAuth({ + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + log: (msg) => ctx.runtime.log(msg), + note: ctx.prompter.note, + prompt: async (message) => String(await ctx.prompter.text({ message })), + progress: spin, + }); + + spin.stop("Gemini CLI OAuth complete"); + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: DEFAULT_MODEL, + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + credentialExtra: { projectId: result.projectId }, + notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], + }); + } catch (err) { + spin.stop("Gemini CLI OAuth failed"); + await ctx.prompter.note( + "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", + "OAuth help", + ); + throw err; + } + }, + }, + ], + resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), + resolveUsageAuth: async (ctx) => { + const auth = await ctx.resolveOAuthToken(); + if (!auth) { + return null; + } + return { + ...auth, + token: parseGoogleUsageToken(auth.token), + }; + }, + fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), + }); +} diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 5691137070b..806133b6419 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -5,6 +5,7 @@ import { } from "../../src/agents/tools/web-search-plugin-factory.js"; import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; const googlePlugin = { id: "google", @@ -12,6 +13,7 @@ const googlePlugin = { description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { + registerGoogleGeminiCliProvider(api); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "gemini", diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google/oauth.test.ts similarity index 99% rename from extensions/google-gemini-cli-auth/oauth.test.ts rename to extensions/google/oauth.test.ts index 02100b73b1f..8aec64d528d 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -1,8 +1,11 @@ import { join, parse } from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -vi.mock("openclaw/plugin-sdk/google-gemini-cli-auth", () => ({ +vi.mock("../../src/infra/wsl.js", () => ({ isWSL2Sync: () => false, +})); + +vi.mock("../../src/infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: async (params: { url: string; init?: RequestInit; diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google/oauth.ts similarity index 99% rename from extensions/google-gemini-cli-auth/oauth.ts rename to extensions/google/oauth.ts index 62881ec3a73..5932b3a237b 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google/oauth.ts @@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; import { delimiter, dirname, join } from "node:path"; -import { fetchWithSsrFGuard, isWSL2Sync } from "openclaw/plugin-sdk/google-gemini-cli-auth"; +import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; +import { isWSL2Sync } from "../../src/infra/wsl.js"; const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 40594e2f3f9..1a6d0dcd196 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "google", + "providers": ["google-gemini-cli"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 2fc0ec447d0..86822b23bf1 100644 --- a/package.json +++ b/package.json @@ -108,10 +108,6 @@ "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" }, - "./plugin-sdk/google-gemini-cli-auth": { - "types": "./dist/plugin-sdk/google-gemini-cli-auth.d.ts", - "default": "./dist/plugin-sdk/google-gemini-cli-auth.js" - }, "./plugin-sdk/googlechat": { "types": "./dist/plugin-sdk/googlechat.d.ts", "default": "./dist/plugin-sdk/googlechat.js" diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 788585b8c54..7b935d183e5 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -14,11 +14,6 @@ const allowedRawFetchCallsites = new Set([ "extensions/feishu/src/streaming-card.ts:101", "extensions/feishu/src/streaming-card.ts:143", "extensions/feishu/src/streaming-card.ts:199", - "extensions/google-gemini-cli-auth/oauth.ts:372", - "extensions/google-gemini-cli-auth/oauth.ts:408", - "extensions/google-gemini-cli-auth/oauth.ts:447", - "extensions/google-gemini-cli-auth/oauth.ts:507", - "extensions/google-gemini-cli-auth/oauth.ts:575", "extensions/googlechat/src/api.ts:22", "extensions/googlechat/src/api.ts:43", "extensions/googlechat/src/api.ts:63", diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 03ff9dfde8f..93fc3fcb545 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -59,7 +59,6 @@ const requiredSubpathEntries = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 34d37634d6f..b8e4fa6706b 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -57,8 +57,6 @@ const requiredPathGroups = [ "dist/plugin-sdk/diffs.d.ts", "dist/plugin-sdk/feishu.js", "dist/plugin-sdk/feishu.d.ts", - "dist/plugin-sdk/google-gemini-cli-auth.js", - "dist/plugin-sdk/google-gemini-cli-auth.d.ts", "dist/plugin-sdk/googlechat.js", "dist/plugin-sdk/googlechat.d.ts", "dist/plugin-sdk/irc.js", diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index beb5db5481b..d0331377432 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -25,7 +25,6 @@ const entrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/src/commands/auth-choice.apply.google-gemini-cli.test.ts b/src/commands/auth-choice.apply.google-gemini-cli.test.ts index f07f970a18d..50a17014908 100644 --- a/src/commands/auth-choice.apply.google-gemini-cli.test.ts +++ b/src/commands/auth-choice.apply.google-gemini-cli.test.ts @@ -77,7 +77,7 @@ describe("applyAuthChoiceGoogleGeminiCli", () => { expect(result).toBe(expected); expect(mockedApplyAuthChoicePluginProvider).toHaveBeenCalledWith(params, { authChoice: "google-gemini-cli", - pluginId: "google-gemini-cli-auth", + pluginId: "google", providerId: "google-gemini-cli", methodId: "oauth", label: "Google Gemini CLI", diff --git a/src/commands/auth-choice.apply.google-gemini-cli.ts b/src/commands/auth-choice.apply.google-gemini-cli.ts index 5fcbc832338..e2aa1d02398 100644 --- a/src/commands/auth-choice.apply.google-gemini-cli.ts +++ b/src/commands/auth-choice.apply.google-gemini-cli.ts @@ -29,7 +29,7 @@ export async function applyAuthChoiceGoogleGeminiCli( return await applyAuthChoicePluginProvider(params, { authChoice: "google-gemini-cli", - pluginId: "google-gemini-cli-auth", + pluginId: "google", providerId: "google-gemini-cli", methodId: "oauth", label: "Google Gemini CLI", diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index c289417ce53..cae9b4e5c18 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -307,7 +307,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); it("auto-enables acpx plugin when ACP is configured", () => { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 4e0cae1209f..72e1dede1ef 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -28,7 +28,7 @@ export type PluginAutoEnableResult = { }; const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ - { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, + { pluginId: "google", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, diff --git a/src/plugin-sdk/google-gemini-cli-auth.ts b/src/plugin-sdk/google-gemini-cli-auth.ts deleted file mode 100644 index a03002feaab..00000000000 --- a/src/plugin-sdk/google-gemini-cli-auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Narrow plugin-sdk surface for the bundled google-gemini-cli-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/google-gemini-cli-auth. - -export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -export { isWSL2Sync } from "../infra/wsl.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderFetchUsageSnapshotContext, - ProviderResolveDynamicModelContext, - ProviderRuntimeModel, -} from "../plugins/types.js"; -export type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 61d1cccb10c..8fe13972e11 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -25,7 +25,6 @@ const pluginSdkEntrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 996c6b27188..09341c4e82b 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -18,10 +18,6 @@ const bundledExtensionSubpathLoaders = [ { id: "diagnostics-otel", load: () => import("openclaw/plugin-sdk/diagnostics-otel") }, { id: "diffs", load: () => import("openclaw/plugin-sdk/diffs") }, { id: "feishu", load: () => import("openclaw/plugin-sdk/feishu") }, - { - id: "google-gemini-cli-auth", - load: () => import("openclaw/plugin-sdk/google-gemini-cli-auth"), - }, { id: "googlechat", load: () => import("openclaw/plugin-sdk/googlechat") }, { id: "irc", load: () => import("openclaw/plugin-sdk/irc") }, { id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") }, diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index 793ed1c7ffe..89259b8a583 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -5,9 +5,9 @@ import { enablePluginInConfig } from "./enable.js"; describe("enablePluginInConfig", () => { it("enables a plugin entry", () => { const cfg: OpenClawConfig = {}; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); it("adds plugin to allowlist when allowlist is configured", () => { @@ -16,18 +16,18 @@ describe("enablePluginInConfig", () => { allow: ["memory-core"], }, }; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.allow).toEqual(["memory-core", "google-gemini-cli-auth"]); + expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]); }); it("refuses enable when plugin is denylisted", () => { const cfg: OpenClawConfig = { plugins: { - deny: ["google-gemini-cli-auth"], + deny: ["google"], }, }; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(false); expect(result.reason).toBe("blocked by denylist"); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 68b83561461..7e18664067b 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -10,7 +10,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "cloudflare-ai-gateway", "copilot-proxy", "github-copilot", - "google-gemini-cli-auth", + "google", "huggingface", "kilocode", "kimi-coding", diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index f938dcc8262..15828b8b7ad 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -31,7 +31,6 @@ "src/plugin-sdk/diagnostics-otel.ts", "src/plugin-sdk/diffs.ts", "src/plugin-sdk/feishu.ts", - "src/plugin-sdk/google-gemini-cli-auth.ts", "src/plugin-sdk/googlechat.ts", "src/plugin-sdk/irc.ts", "src/plugin-sdk/llm-task.ts", diff --git a/tsdown.config.ts b/tsdown.config.ts index 6ed9ccb930b..2b7c9dbe192 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -77,7 +77,6 @@ const pluginSdkEntrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/vitest.config.ts b/vitest.config.ts index 70011a6a0b8..c45f5f45c25 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,7 +27,6 @@ const pluginSdkSubpaths = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", From b54e37c71f4d3dda7ed2a4024dd28dbba3f9641c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:50:16 -0700 Subject: [PATCH 011/331] feat(plugins): merge openai vendor seams into one plugin --- docs/concepts/model-providers.md | 19 +- docs/tools/plugin.md | 45 +++-- extensions/openai-codex/openclaw.plugin.json | 9 - extensions/openai-codex/package.json | 12 -- extensions/openai/index.test.ts | 41 ++++- extensions/openai/index.ts | 135 +-------------- .../openai-codex-provider.ts} | 162 ++++++++---------- .../openai-codex.test.ts} | 18 +- extensions/openai/openai-provider.ts | 143 ++++++++++++++++ extensions/openai/openclaw.plugin.json | 2 +- extensions/openai/package.json | 2 +- extensions/openai/shared.ts | 57 ++++++ src/agents/model-auth.ts | 21 ++- src/agents/model-catalog.ts | 89 +++------- src/agents/model-forward-compat.ts | 158 +---------------- src/agents/model-suppression.ts | 31 ++-- src/agents/pi-embedded-runner/model.ts | 2 +- src/plugin-sdk/core.ts | 4 + src/plugin-sdk/index.ts | 4 + src/plugins/config-state.test.ts | 16 ++ src/plugins/config-state.ts | 29 +++- src/plugins/provider-runtime.test.ts | 134 ++++++++++++--- src/plugins/provider-runtime.ts | 81 ++++++++- src/plugins/providers.test.ts | 18 ++ src/plugins/providers.ts | 61 ++++++- src/plugins/types.ts | 88 ++++++++++ 26 files changed, 833 insertions(+), 548 deletions(-) delete mode 100644 extensions/openai-codex/openclaw.plugin.json delete mode 100644 extensions/openai-codex/package.json rename extensions/{openai-codex/index.ts => openai/openai-codex-provider.ts} (59%) rename extensions/{openai-codex/index.test.ts => openai/openai-codex.test.ts} (87%) create mode 100644 extensions/openai/openai-provider.ts create mode 100644 extensions/openai/shared.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index d20b5055763..23fe7edcd1d 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -22,8 +22,9 @@ For model selection rules, see [/concepts/models](/concepts/models). - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, - `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, and - `fetchUsageSnapshot`. + `isCacheTtlEligible`, `buildMissingAuthMessage`, + `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, + `resolveUsageAuth`, and `fetchUsageSnapshot`. ## Plugin-owned provider behavior @@ -42,6 +43,12 @@ Typical split: - `prepareExtraParams`: provider defaults or normalizes per-model request params - `wrapStreamFn`: provider applies request headers/body/model compat wrappers - `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL +- `buildMissingAuthMessage`: provider replaces the generic auth-store error + with a provider-specific recovery hint +- `suppressBuiltInModel`: provider hides stale upstream rows and can return a + vendor-owned error for direct resolution failures +- `augmentModelCatalog`: provider appends synthetic/final catalog rows after + discovery and config merging - `prepareRuntimeAuth`: provider turns a configured credential into a short lived runtime token - `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage` @@ -58,9 +65,8 @@ Current bundled examples: - `github-copilot`: forward-compat model fallback, Claude-thinking transcript hints, runtime token exchange, and usage endpoint fetching - `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport - normalization, and provider-family metadata -- `openai-codex`: forward-compat model fallback, transport normalization, and - default transport params plus usage endpoint fetching + normalization, Codex-aware missing-auth hints, Spark suppression, synthetic + OpenAI/Codex catalog rows, and provider-family metadata - `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token parsing and quota endpoint fetching for usage surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization @@ -75,6 +81,9 @@ Current bundled examples: plugin-owned catalogs only - `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic +The bundled `openai` plugin now owns both provider ids: `openai` and +`openai-codex`. + That covers providers that still fit OpenClaw's normal transports. A provider that needs a totally custom request executor is a separate, deeper extension surface. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 59752ddf253..1cfe6ae1cd0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -178,8 +178,7 @@ Important trust note: - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) - NVIDIA provider catalog — bundled as `nvidia` (enabled by default) -- OpenAI provider runtime — bundled as `openai` (enabled by default) -- OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) +- OpenAI provider runtime — bundled as `openai` (enabled by default; owns both `openai` and `openai-codex`) - OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default) - OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default) - OpenRouter provider runtime — bundled as `openrouter` (enabled by default) @@ -207,7 +206,7 @@ Native OpenClaw plugins can register: - Background services - Context engines - Provider auth flows and model catalogs -- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, runtime auth exchange, and usage/billing auth + snapshot resolution +- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, missing-auth hints, built-in model suppression, catalog augmentation, runtime auth exchange, and usage/billing auth + snapshot resolution - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -220,7 +219,7 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -251,13 +250,20 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: Provider-owned stream wrapper after generic wrappers are applied. 9. `isCacheTtlEligible` Provider-owned prompt-cache policy for proxy/backhaul providers. -10. `prepareRuntimeAuth` +10. `buildMissingAuthMessage` + Provider-owned replacement for the generic missing-auth recovery message. +11. `suppressBuiltInModel` + Provider-owned stale upstream model suppression plus optional user-facing + error hint. +12. `augmentModelCatalog` + Provider-owned synthetic/final catalog rows appended after discovery. +13. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. -11. `resolveUsageAuth` +14. `resolveUsageAuth` Resolves usage/billing credentials for `/usage` and related status surfaces. -12. `fetchUsageSnapshot` +15. `fetchUsageSnapshot` Fetches and normalizes provider-specific usage/quota snapshots after auth is resolved. @@ -271,6 +277,9 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping - `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path - `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata +- `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint +- `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures +- `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging - `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests - `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core - `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting @@ -285,6 +294,9 @@ Rule of thumb: - provider needs default request params or per-provider param cleanup: use `prepareExtraParams` - provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn` - provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible` +- provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage` +- provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel` +- provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog` - provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` - provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth` - provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot` @@ -354,8 +366,10 @@ api.registerProvider({ forward-compat, provider-family hints, usage endpoint integration, and prompt-cache eligibility. - OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and - `capabilities` because it owns GPT-5.4 forward-compat plus the direct OpenAI - `openai-completions` -> `openai-responses` normalization. + `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, and + `augmentModelCatalog` because it owns GPT-5.4 forward-compat, the direct + OpenAI `openai-completions` -> `openai-responses` normalization, Codex-aware + auth hints, Spark suppression, and synthetic OpenAI list rows. - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. @@ -363,11 +377,12 @@ api.registerProvider({ `capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it needs model fallback behavior, Claude transcript quirks, a GitHub token -> Copilot token exchange, and a provider-owned usage endpoint. -- OpenAI Codex uses `catalog`, `resolveDynamicModel`, and - `normalizeResolvedModel` plus `prepareExtraParams`, `resolveUsageAuth`, and - `fetchUsageSnapshot` because it still runs on core OpenAI transports but owns - its transport/base URL normalization, default transport choice, and ChatGPT - usage endpoint integration. +- OpenAI Codex uses `catalog`, `resolveDynamicModel`, + `normalizeResolvedModel`, and `augmentModelCatalog` plus + `prepareExtraParams`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it + still runs on core OpenAI transports but owns its transport/base URL + normalization, default transport choice, synthetic Codex catalog rows, and + ChatGPT usage endpoint integration. - Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus the token parsing and quota endpoint wiring needed by `/usage`. @@ -654,7 +669,7 @@ Default-on bundled plugin examples: - `moonshot` - `nvidia` - `ollama` -- `openai-codex` +- `openai` - `openrouter` - `phone-control` - `qianfan` diff --git a/extensions/openai-codex/openclaw.plugin.json b/extensions/openai-codex/openclaw.plugin.json deleted file mode 100644 index 0dfd4106a9a..00000000000 --- a/extensions/openai-codex/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "openai-codex", - "providers": ["openai-codex"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/openai-codex/package.json b/extensions/openai-codex/package.json deleted file mode 100644 index 49730240ff8..00000000000 --- a/extensions/openai-codex/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/openai-codex-provider", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw OpenAI Codex provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index cdf2d1f8a27..32b5b4b3a63 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -2,22 +2,31 @@ import { describe, expect, it } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; import openAIPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; +function registerProviders(): ProviderPlugin[] { + const providers: ProviderPlugin[] = []; openAIPlugin.register({ registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; + providers.push(nextProvider); }, } as never); + return providers; +} + +function requireProvider(id: string): ProviderPlugin { + const provider = registerProviders().find((entry) => entry.id === id); if (!provider) { - throw new Error("provider registration missing"); + throw new Error(`provider registration missing for ${id}`); } return provider; } describe("openai plugin", () => { + it("registers openai and openai-codex providers from one extension", () => { + expect(registerProviders().map((provider) => provider.id)).toEqual(["openai", "openai-codex"]); + }); + it("owns openai gpt-5.4 forward-compat resolution", () => { - const provider = registerProvider(); + const provider = requireProvider("openai"); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-pro", @@ -51,7 +60,7 @@ describe("openai plugin", () => { }); it("owns direct openai transport normalization", () => { - const provider = registerProvider(); + const provider = requireProvider("openai"); expect( provider.normalizeResolvedModel?.({ provider: "openai", @@ -73,4 +82,24 @@ describe("openai plugin", () => { api: "openai-responses", }); }); + + it("owns codex-only missing-auth hints and Spark suppression", () => { + const provider = requireProvider("openai"); + expect( + provider.buildMissingAuthMessage?.({ + env: {} as NodeJS.ProcessEnv, + provider: "openai", + listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), + }), + ).toContain("openai-codex/gpt-5.4"); + expect( + provider.suppressBuiltInModel?.({ + env: {} as NodeJS.ProcessEnv, + provider: "azure-openai-responses", + modelId: "gpt-5.3-codex-spark", + }), + ).toMatchObject({ + suppress: true, + }); + }); }); diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index cc2ca6fe4a0..3a01aad8db9 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,136 +1,15 @@ -import { - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; -import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; - -const PROVIDER_ID = "openai"; -const OPENAI_BASE_URL = "https://api.openai.com/v1"; -const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; -const OPENAI_GPT_54_MAX_TOKENS = 128_000; -const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; -const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; - -function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); -} - -function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { - const useResponsesTransport = - model.api === "openai-completions" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)); - - if (!useResponsesTransport) { - return model; - } - - return { - ...model, - api: "openai-responses", - }; -} - -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; -} - -function resolveOpenAIGpt54ForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmedModelId = ctx.modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - let templateIds: readonly string[]; - if (lower === OPENAI_GPT_54_MODEL_ID) { - templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; - } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; - } else { - return undefined; - } - - return ( - cloneFirstTemplateModel({ - modelId: trimmedModelId, - templateIds, - ctx, - patch: { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: OPENAI_BASE_URL, - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, - }) ?? - normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: OPENAI_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - } as ProviderRuntimeModel) - ); -} +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; +import { buildOpenAIProvider } from "./openai-provider.js"; const openAIPlugin = { - id: PROVIDER_ID, + id: "openai", name: "OpenAI Provider", - description: "Bundled OpenAI provider plugin", + description: "Bundled OpenAI provider plugins", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: "OpenAI", - docsPath: "/providers/models", - envVars: ["OPENAI_API_KEY"], - auth: [], - resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), - normalizeResolvedModel: (ctx) => { - if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { - return undefined; - } - return normalizeOpenAITransport(ctx.model); - }, - capabilities: { - providerFamily: "openai", - }, - }); + api.registerProvider(buildOpenAIProvider()); + api.registerProvider(buildOpenAICodexProviderPlugin()); }, }; diff --git a/extensions/openai-codex/index.ts b/extensions/openai/openai-codex-provider.ts similarity index 59% rename from extensions/openai-codex/index.ts rename to extensions/openai/openai-codex-provider.ts index 9d8ee0769af..af5f85d4d21 100644 --- a/extensions/openai-codex/index.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -1,8 +1,6 @@ -import { - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; @@ -11,6 +9,8 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; @@ -24,14 +24,6 @@ const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; -function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); -} - function isOpenAICodexBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { @@ -59,31 +51,6 @@ function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeMo }; } -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; -} - function resolveCodexForwardCompatModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel | undefined { @@ -118,6 +85,7 @@ function resolveCodexForwardCompatModel( return ( cloneFirstTemplateModel({ + providerId: PROVIDER_ID, modelId: trimmedModelId, templateIds, ctx, @@ -138,56 +106,76 @@ function resolveCodexForwardCompatModel( ); } -const openAICodexPlugin = { - id: "openai-codex", - name: "OpenAI Codex Provider", - description: "Bundled OpenAI Codex provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: "OpenAI Codex", - docsPath: "/providers/models", - auth: [], - catalog: { - order: "profile", - run: async (ctx) => { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) { - return null; - } - return { - provider: buildOpenAICodexProvider(), - }; - }, - }, - resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), - capabilities: { - providerFamily: "openai", - }, - prepareExtraParams: (ctx) => { - const transport = ctx.extraParams?.transport; - if (transport === "auto" || transport === "sse" || transport === "websocket") { - return ctx.extraParams; +export function buildOpenAICodexProviderPlugin(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [], + catalog: { + order: "profile", + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) { + return null; } return { - ...ctx.extraParams, - transport: "auto", + provider: buildOpenAICodexProvider(), }; }, - normalizeResolvedModel: (ctx) => { - if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { - return undefined; - } - return normalizeCodexTransport(ctx.model); - }, - resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), - fetchUsageSnapshot: async (ctx) => - await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), - }); - }, -}; - -export default openAICodexPlugin; + }, + resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: (ctx) => { + const transport = ctx.extraParams?.transport; + if (transport === "auto" || transport === "sse" || transport === "websocket") { + return ctx.extraParams; + } + return { + ...ctx.extraParams, + transport: "auto", + }; + }, + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeCodexTransport(ctx.model); + }, + resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), + fetchUsageSnapshot: async (ctx) => + await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), + augmentModelCatalog: (ctx) => { + const gpt54Template = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS, + }); + const sparkTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS], + }); + return [ + gpt54Template + ? { + ...gpt54Template, + id: OPENAI_CODEX_GPT_54_MODEL_ID, + name: OPENAI_CODEX_GPT_54_MODEL_ID, + } + : undefined, + sparkTemplate + ? { + ...sparkTemplate, + id: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + name: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); + }, + }; +} diff --git a/extensions/openai-codex/index.test.ts b/extensions/openai/openai-codex.test.ts similarity index 87% rename from extensions/openai-codex/index.test.ts rename to extensions/openai/openai-codex.test.ts index 53bbd700f17..bbf77320b26 100644 --- a/extensions/openai-codex/index.test.ts +++ b/extensions/openai/openai-codex.test.ts @@ -4,13 +4,15 @@ import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; -import openAICodexPlugin from "./index.js"; +import openAIPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { +function registerCodexProvider(): ProviderPlugin { let provider: ProviderPlugin | undefined; - openAICodexPlugin.register({ + openAIPlugin.register({ registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; + if (nextProvider.id === "openai-codex") { + provider = nextProvider; + } }, } as never); if (!provider) { @@ -19,9 +21,9 @@ function registerProvider(): ProviderPlugin { return provider; } -describe("openai-codex plugin", () => { +describe("openai codex provider", () => { it("owns forward-compat codex models", () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); const model = provider.resolveDynamicModel?.({ provider: "openai-codex", modelId: "gpt-5.4", @@ -54,7 +56,7 @@ describe("openai-codex plugin", () => { }); it("owns codex transport defaults", () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); expect( provider.prepareExtraParams?.({ provider: "openai-codex", @@ -68,7 +70,7 @@ describe("openai-codex plugin", () => { }); it("owns usage snapshot fetching", async () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("chatgpt.com/backend-api/wham/usage")) { return makeResponse(200, { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts new file mode 100644 index 00000000000..9ce61e2a2b8 --- /dev/null +++ b/extensions/openai/openai-provider.ts @@ -0,0 +1,143 @@ +import { + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; + +const PROVIDER_ID = "openai"; +const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; +const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; +const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { + const useResponsesTransport = + model.api === "openai-completions" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)); + + if (!useResponsesTransport) { + return model; + } + + return { + ...model, + api: "openai-responses", + }; +} + +function resolveOpenAIGpt54ForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + let templateIds: readonly string[]; + if (lower === OPENAI_GPT_54_MODEL_ID) { + templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { + templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + } else { + return undefined; + } + + return ( + cloneFirstTemplateModel({ + providerId: PROVIDER_ID, + modelId: trimmedModelId, + templateIds, + ctx, + patch: { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }, + }) ?? + normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + } as ProviderRuntimeModel) + ); +} + +export function buildOpenAIProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeOpenAITransport(ctx.model); + }, + capabilities: { + providerFamily: "openai", + }, + buildMissingAuthMessage: (ctx) => { + if (ctx.provider !== PROVIDER_ID || ctx.listProfileIds("openai-codex").length === 0) { + return undefined; + } + return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.'; + }, + suppressBuiltInModel: (ctx) => { + if ( + !SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(ctx.provider)) || + ctx.modelId.toLowerCase() !== OPENAI_DIRECT_SPARK_MODEL_ID + ) { + return undefined; + } + return { + suppress: true, + errorMessage: `Unknown model: ${ctx.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`, + }; + }, + augmentModelCatalog: (ctx) => { + const openAiGpt54Template = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_TEMPLATE_MODEL_IDS, + }); + const openAiGpt54ProTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS, + }); + return [ + openAiGpt54Template + ? { + ...openAiGpt54Template, + id: OPENAI_GPT_54_MODEL_ID, + name: OPENAI_GPT_54_MODEL_ID, + } + : undefined, + openAiGpt54ProTemplate + ? { + ...openAiGpt54ProTemplate, + id: OPENAI_GPT_54_PRO_MODEL_ID, + name: OPENAI_GPT_54_PRO_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); + }, + }; +} diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 4bae96f3619..480e80a59ce 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -1,6 +1,6 @@ { "id": "openai", - "providers": ["openai"], + "providers": ["openai", "openai-codex"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/package.json b/extensions/openai/package.json index c5e73ed8120..1e4599dc157 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/openai-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw OpenAI provider plugin", + "description": "OpenClaw OpenAI provider plugins", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts new file mode 100644 index 00000000000..c8654be2f9b --- /dev/null +++ b/extensions/openai/shared.ts @@ -0,0 +1,57 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; + +export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; + +export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +export function cloneFirstTemplateModel(params: { + providerId: string; + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; + patch?: Partial; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + ...params.patch, + } as ProviderRuntimeModel); + } + return undefined; +} + +export function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index fb3abd1571e..7064b2fcd01 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -6,6 +6,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -358,13 +359,19 @@ export async function resolveApiKeyForProvider(params: { return resolveAwsSdkAuthInfo(); } - if (provider === "openai") { - const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; - if (hasCodex) { - throw new Error( - 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.', - ); - } + const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({ + provider, + config: cfg, + context: { + config: cfg, + agentDir: params.agentDir, + env: process.env, + provider, + listProfileIds: (providerId) => listProfilesForProvider(store, providerId), + }, + }); + if (pluginMissingAuthMessage) { + throw new Error(pluginMissingAuthMessage); } const authStorePath = resolveAuthStorePathForDisplay(params.agentDir); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 6f66e85c49c..4274333a518 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,5 +1,6 @@ import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { shouldSuppressBuiltInModel } from "./model-suppression.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -33,70 +34,8 @@ let hasLoggedModelCatalogError = false; const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); let importPiSdk = defaultImportPiSdk; -const CODEX_PROVIDER = "openai-codex"; -const OPENAI_PROVIDER = "openai"; -const OPENAI_GPT54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex"; -const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; -const OPENAI_CODEX_GPT54_MODEL_ID = "gpt-5.4"; const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); -type SyntheticCatalogFallback = { - provider: string; - id: string; - templateIds: readonly string[]; -}; - -const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [ - { - provider: OPENAI_PROVIDER, - id: OPENAI_GPT54_MODEL_ID, - templateIds: ["gpt-5.2"], - }, - { - provider: OPENAI_PROVIDER, - id: OPENAI_GPT54_PRO_MODEL_ID, - templateIds: ["gpt-5.2-pro", "gpt-5.2"], - }, - { - provider: CODEX_PROVIDER, - id: OPENAI_CODEX_GPT54_MODEL_ID, - templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], - }, - { - provider: CODEX_PROVIDER, - id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID, - templateIds: [OPENAI_CODEX_GPT53_MODEL_ID], - }, -] as const; - -function applySyntheticCatalogFallbacks(models: ModelCatalogEntry[]): void { - const findCatalogEntry = (provider: string, id: string) => - models.find( - (entry) => - entry.provider.toLowerCase() === provider.toLowerCase() && - entry.id.toLowerCase() === id.toLowerCase(), - ); - - for (const fallback of SYNTHETIC_CATALOG_FALLBACKS) { - if (findCatalogEntry(fallback.provider, fallback.id)) { - continue; - } - const template = fallback.templateIds - .map((templateId) => findCatalogEntry(fallback.provider, templateId)) - .find((entry) => entry !== undefined); - if (!template) { - continue; - } - models.push({ - ...template, - id: fallback.id, - name: fallback.id, - }); - } -} - function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { if (!Array.isArray(input)) { return undefined; @@ -256,7 +195,31 @@ export async function loadModelCatalog(params?: { models.push({ id, name, provider, contextWindow, reasoning, input }); } mergeConfiguredOptInProviderModels({ config: cfg, models }); - applySyntheticCatalogFallbacks(models); + const supplemental = await augmentModelCatalogWithProviderPlugins({ + config: cfg, + env: process.env, + context: { + config: cfg, + agentDir, + env: process.env, + entries: [...models], + }, + }); + if (supplemental.length > 0) { + const seen = new Set( + models.map( + (entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`, + ), + ); + for (const entry of supplemental) { + const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`; + if (seen.has(key)) { + continue; + } + models.push(entry); + seen.add(key); + } + } if (models.length === 0) { // If we found nothing, don't cache this result so we can try again. diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index 709afc2ee4d..5319d30423e 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -4,83 +4,18 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; import { normalizeModelCompat } from "./model-compat.js"; import { normalizeProviderId } from "./model-selection.js"; -const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; -const OPENAI_GPT_54_MAX_TOKENS = 128_000; -const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; -const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; - -const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; -const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; -const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; -const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6"; -const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6"; -const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const; - const ZAI_GLM5_MODEL_ID = "glm-5"; const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not yet in pi-ai's built-in -// google-gemini-cli catalog. Clone the gemini-3-pro/flash-preview template so users -// don't get "Unknown model" errors when Google releases a new minor version. +// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai +// Google catalogs yet. Clone the nearest gemini-3 template so users don't get +// "Unknown model" errors when Google ships new minor-version models before pi-ai +// updates its built-in registry. const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; -function resolveOpenAIGpt54ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "openai") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - let templateIds: readonly string[]; - if (lower === OPENAI_GPT_54_MODEL_ID) { - templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; - } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; - } else { - return undefined; - } - - return ( - cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds: [...templateIds], - modelRegistry, - patch: { - api: "openai-responses", - provider: normalizedProvider, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, - }) ?? - normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-responses", - provider: normalizedProvider, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - } as Model) - ); -} - function cloneFirstTemplateModel(params: { normalizedProvider: string; trimmedModelId: string; @@ -104,88 +39,6 @@ function cloneFirstTemplateModel(params: { return undefined; } -function resolveAnthropic46ForwardCompatModel(params: { - provider: string; - modelId: string; - modelRegistry: ModelRegistry; - dashModelId: string; - dotModelId: string; - dashTemplateId: string; - dotTemplateId: string; - fallbackTemplateIds: readonly string[]; -}): Model | undefined { - const { provider, modelId, modelRegistry, dashModelId, dotModelId } = params; - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "anthropic") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - const is46Model = - lower === dashModelId || - lower === dotModelId || - lower.startsWith(`${dashModelId}-`) || - lower.startsWith(`${dotModelId}-`); - if (!is46Model) { - return undefined; - } - - const templateIds: string[] = []; - if (lower.startsWith(dashModelId)) { - templateIds.push(lower.replace(dashModelId, params.dashTemplateId)); - } - if (lower.startsWith(dotModelId)) { - templateIds.push(lower.replace(dotModelId, params.dotTemplateId)); - } - templateIds.push(...params.fallbackTemplateIds); - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds, - modelRegistry, - }); -} - -function resolveAnthropicOpus46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return resolveAnthropic46ForwardCompatModel({ - provider, - modelId, - modelRegistry, - dashModelId: ANTHROPIC_OPUS_46_MODEL_ID, - dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID, - dashTemplateId: "claude-opus-4-5", - dotTemplateId: "claude-opus-4.5", - fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS, - }); -} - -function resolveAnthropicSonnet46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return resolveAnthropic46ForwardCompatModel({ - provider, - modelId, - modelRegistry, - dashModelId: ANTHROPIC_SONNET_46_MODEL_ID, - dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID, - dashTemplateId: "claude-sonnet-4-5", - dotTemplateId: "claude-sonnet-4.5", - fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS, - }); -} - -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai -// Google catalogs yet. Clone the nearest gemini-3 template so users don't get -// "Unknown model" errors when Google ships new minor-version models before pi-ai -// updates its built-in registry. function resolveGoogle31ForwardCompatModel( provider: string, modelId: string, @@ -264,9 +117,6 @@ export function resolveForwardCompatModel( modelRegistry: ModelRegistry, ): Model | undefined { return ( - resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry) ); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index 378096ea732..ac1dcccdb74 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,27 +1,32 @@ +import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; import { normalizeProviderId } from "./model-selection.js"; -const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; -const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); +function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) { + const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); + const modelId = params.id?.trim().toLowerCase() ?? ""; + if (!provider || !modelId) { + return undefined; + } + return resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider, + modelId, + }, + }); +} export function shouldSuppressBuiltInModel(params: { provider?: string | null; id?: string | null; }) { - const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); - const id = params.id?.trim().toLowerCase() ?? ""; - - // pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as - // Codex-only until upstream availability is proven on direct API paths. - return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID; + return resolveBuiltInModelSuppression(params)?.suppress ?? false; } export function buildSuppressedBuiltInModelError(params: { provider?: string | null; id?: string | null; }): string | undefined { - if (!shouldSuppressBuiltInModel(params)) { - return undefined; - } - const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai"; - return `Unknown model: ${provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`; + return resolveBuiltInModelSuppression(params)?.errorMessage; } diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 7263155c1ad..ed6356a361f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -34,7 +34,7 @@ type InlineProviderConfig = { headers?: unknown; }; -const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["anthropic", "google-gemini-cli", "openai", "zai"]); +const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); function sanitizeModelHeaders( headers: unknown, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index d8b94a53545..4f403343b34 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -4,6 +4,10 @@ export type { ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, + ProviderBuiltInModelSuppressionResult, + ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5c8c514d191..089876dc7bc 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -109,6 +109,10 @@ export type { PluginLogger, ProviderAuthContext, ProviderAuthResult, + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, + ProviderBuiltInModelSuppressionResult, + ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 2d287a71e34..37db8a6efae 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -77,6 +77,22 @@ describe("normalizePluginsConfig", () => { }); expect(result.entries["voice-call"]?.hooks).toBeUndefined(); }); + + it("normalizes legacy plugin ids to their merged bundled plugin id", () => { + const result = normalizePluginsConfig({ + allow: ["openai-codex"], + deny: ["openai-codex"], + entries: { + "openai-codex": { + enabled: true, + }, + }, + }); + + expect(result.allow).toEqual(["openai"]); + expect(result.deny).toEqual(["openai"]); + expect(result.entries.openai?.enabled).toBe(true); + }); }); describe("resolveEffectiveEnableState", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 26a65b61cd9..a5860b606e3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -40,7 +40,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "nvidia", "ollama", "openai", - "openai-codex", "opencode", "opencode-go", "openrouter", @@ -59,11 +58,22 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "zai", ]); +const PLUGIN_ID_ALIASES: Readonly> = { + "openai-codex": "openai", +}; + +function normalizePluginId(id: string): string { + const trimmed = id.trim(); + return PLUGIN_ID_ALIASES[trimmed] ?? trimmed; +} + const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) { return []; } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); + return value + .map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : "")) + .filter(Boolean); }; const normalizeSlotValue = (value: unknown): string | null | undefined => { @@ -86,11 +96,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr } const normalized: NormalizedPluginsConfig["entries"] = {}; for (const [key, value] of Object.entries(entries)) { - if (!key.trim()) { + const normalizedKey = normalizePluginId(key); + if (!normalizedKey) { continue; } if (!value || typeof value !== "object" || Array.isArray(value)) { - normalized[key] = {}; + normalized[normalizedKey] = {}; continue; } const entry = value as Record; @@ -108,10 +119,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr allowPromptInjection: hooks.allowPromptInjection, } : undefined; - normalized[key] = { - enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined, - hooks: normalizedHooks, - config: "config" in entry ? entry.config : undefined, + normalized[normalizedKey] = { + ...normalized[normalizedKey], + enabled: + typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, + hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, + config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, }; } return normalized; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 1ca9ef446b6..af5066b5453 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -8,8 +8,11 @@ vi.mock("./providers.js", () => ({ })); import { + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, prepareProviderExtraParams, resolveProviderCacheTtlEligibility, + resolveProviderBuiltInModelSuppression, resolveProviderUsageSnapshotWithPlugin, resolveProviderCapabilitiesWithPlugin, resolveProviderUsageAuthWithPlugin, @@ -57,6 +60,7 @@ describe("provider-runtime", () => { expect.objectContaining({ provider: "Open Router", bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, }), ); }); @@ -77,31 +81,59 @@ describe("provider-runtime", () => { displayName: "Demo", windows: [{ label: "Day", usedPercent: 25 }], })); - resolvePluginProvidersMock.mockReturnValue([ - { - id: "demo", - label: "Demo", - auth: [], - resolveDynamicModel: () => MODEL, - prepareDynamicModel, - capabilities: { - providerFamily: "openai", + resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { + if (params?.onlyPluginIds?.includes("openai")) { + return [ + { + id: "openai", + label: "OpenAI", + auth: [], + buildMissingAuthMessage: () => + 'No API key found for provider "openai". Use openai-codex/gpt-5.4.', + suppressBuiltInModel: ({ provider, modelId }) => + provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark" + ? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" } + : undefined, + augmentModelCatalog: () => [ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ], + }, + ]; + } + + return [ + { + id: "demo", + label: "Demo", + auth: [], + resolveDynamicModel: () => MODEL, + prepareDynamicModel, + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: ({ extraParams }) => ({ + ...extraParams, + transport: "auto", + }), + wrapStreamFn: ({ streamFn }) => streamFn, + normalizeResolvedModel: ({ model }) => ({ + ...model, + api: "openai-codex-responses", + }), + prepareRuntimeAuth, + resolveUsageAuth, + fetchUsageSnapshot, + isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), }, - prepareExtraParams: ({ extraParams }) => ({ - ...extraParams, - transport: "auto", - }), - wrapStreamFn: ({ streamFn }) => streamFn, - normalizeResolvedModel: ({ model }) => ({ - ...model, - api: "openai-codex-responses", - }), - prepareRuntimeAuth, - resolveUsageAuth, - fetchUsageSnapshot, - isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), - }, - ]); + ]; + }); expect( runProviderDynamicModel({ @@ -234,6 +266,60 @@ describe("provider-runtime", () => { }), ).toBe(true); + expect( + buildProviderMissingAuthMessageWithPlugin({ + provider: "openai", + env: process.env, + context: { + env: process.env, + provider: "openai", + listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), + }, + }), + ).toContain("openai-codex/gpt-5.4"); + + expect( + resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider: "azure-openai-responses", + modelId: "gpt-5.3-codex-spark", + }, + }), + ).toMatchObject({ + suppress: true, + errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"), + }); + + await expect( + augmentModelCatalogWithProviderPlugins({ + env: process.env, + context: { + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], + }, + }), + ).resolves.toEqual([ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ]); + + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["openai"], + }), + ); expect(prepareDynamicModel).toHaveBeenCalledTimes(1); expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1); expect(resolveUsageAuth).toHaveBeenCalledTimes(1); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 7397a52abae..e7ee62d8ebf 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -2,6 +2,9 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginProviders } from "./providers.js"; import type { + ProviderAugmentModelCatalogContext, + ProviderBuildMissingAuthMessageContext, + ProviderBuiltInModelSuppressionContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPrepareExtraParamsContext, @@ -25,16 +28,41 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); } +function resolveProviderPluginsForHooks(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; +}): ProviderPlugin[] { + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); +} + +const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const; + +function resolveGlobalProviderHookPlugins(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS], + }); +} + export function resolveProviderRuntimePlugin(params: { provider: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolvePluginProviders({ - ...params, - bundledProviderAllowlistCompat: true, - }).find((plugin) => matchesProviderId(plugin, params.provider)); + return resolveProviderPluginsForHooks(params).find((plugin) => + matchesProviderId(plugin, params.provider), + ); } export function runProviderDynamicModel(params: { @@ -144,3 +172,48 @@ export function resolveProviderCacheTtlEligibility(params: { }) { return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context); } + +export function buildProviderMissingAuthMessageWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderBuildMissingAuthMessageContext; +}) { + const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) => + matchesProviderId(providerPlugin, params.provider), + ); + return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined; +} + +export function resolveProviderBuiltInModelSuppression(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderBuiltInModelSuppressionContext; +}) { + for (const plugin of resolveGlobalProviderHookPlugins(params)) { + const result = plugin.suppressBuiltInModel?.(params.context); + if (result?.suppress) { + return result; + } + } + return undefined; +} + +export async function augmentModelCatalogWithProviderPlugins(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderAugmentModelCatalogContext; +}) { + const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; + for (const plugin of resolveGlobalProviderHookPlugins(params)) { + const next = await plugin.augmentModelCatalog?.(params.context); + if (!next || next.length === 0) { + continue; + } + supplemental.push(...next); + } + return supplemental; +} diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 7df6432b4c3..4e238c2193d 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -52,4 +52,22 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => { + resolvePluginProviders({ + env: { VITEST: "1" } as NodeJS.ProcessEnv, + bundledProviderVitestCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + enabled: true, + allow: expect.arrayContaining(["openai", "moonshot", "zai"]), + }), + }), + }), + ); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 7e18664067b..010766e5fa9 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -22,7 +22,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "nvidia", "ollama", "openai", - "openai-codex", "opencode", "opencode-go", "openrouter", @@ -39,6 +38,32 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "zai", ] as const; +function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { + const plugins = config?.plugins; + if (!plugins) { + return false; + } + if (typeof plugins.enabled === "boolean") { + return true; + } + if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { + return true; + } + if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { + return true; + } + if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { + return true; + } + if (plugins.entries && Object.keys(plugins.entries).length > 0) { + return true; + } + if (plugins.slots && Object.keys(plugins.slots).length > 0) { + return true; + } + return false; +} + function withBundledProviderAllowlistCompat( config: PluginLoadOptions["config"], ): PluginLoadOptions["config"] { @@ -71,20 +96,52 @@ function withBundledProviderAllowlistCompat( }; } +function withBundledProviderVitestCompat(params: { + config: PluginLoadOptions["config"]; + env?: PluginLoadOptions["env"]; +}): PluginLoadOptions["config"] { + const env = params.env ?? process.env; + if (!env.VITEST || hasExplicitPluginConfig(params.config)) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + enabled: true, + allow: [...BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS], + slots: { + ...params.config?.plugins?.slots, + memory: "none", + }, + }, + }; +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: PluginLoadOptions["env"]; bundledProviderAllowlistCompat?: boolean; + bundledProviderVitestCompat?: boolean; + onlyPluginIds?: string[]; }): ProviderPlugin[] { - const config = params.bundledProviderAllowlistCompat + const maybeAllowlistCompat = params.bundledProviderAllowlistCompat ? withBundledProviderAllowlistCompat(params.config) : params.config; + const config = params.bundledProviderVitestCompat + ? withBundledProviderVitestCompat({ + config: maybeAllowlistCompat, + env: params.env, + }) + : maybeAllowlistCompat; const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, env: params.env, + onlyPluginIds: params.onlyPluginIds, logger: createPluginLoaderLogger(log), }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d96a8c65d8d..9ad44fff40d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -10,6 +10,7 @@ import type { AuthProfileCredential, OAuthCredential, } from "../agents/auth-profiles/types.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; @@ -390,6 +391,59 @@ export type ProviderCacheTtlEligibilityContext = { modelId: string; }; +/** + * Provider-owned missing-auth message override. + * + * Runs only after OpenClaw exhausts normal env/profile/config auth resolution + * for the requested provider. Return a custom message to replace the generic + * "No API key found" error. + */ +export type ProviderBuildMissingAuthMessageContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + listProfileIds: (providerId: string) => string[]; +}; + +/** + * Built-in model suppression hook. + * + * Use this when a provider/plugin needs to hide stale upstream catalog rows or + * replace them with a vendor-specific hint. This hook is consulted by model + * resolution, model listing, and catalog loading. + */ +export type ProviderBuiltInModelSuppressionContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + modelId: string; +}; + +export type ProviderBuiltInModelSuppressionResult = { + suppress: boolean; + errorMessage?: string; +}; + +/** + * Final catalog augmentation hook. + * + * Runs after OpenClaw loads the discovered model catalog and merges configured + * opt-in providers. Use this for forward-compat rows or vendor-owned synthetic + * entries that should appear in `models list` and model pickers even when the + * upstream registry has not caught up yet. + */ +export type ProviderAugmentModelCatalogContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + entries: ModelCatalogEntry[]; +}; + /** * @deprecated Use ProviderCatalogOrder. */ @@ -560,6 +614,40 @@ export type ProviderPlugin = { * only a subset of upstream models. */ isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined; + /** + * Provider-owned missing-auth message override. + * + * Return a custom message when the provider wants a more specific recovery + * hint than OpenClaw's generic auth-store guidance. + */ + buildMissingAuthMessage?: ( + ctx: ProviderBuildMissingAuthMessageContext, + ) => string | null | undefined; + /** + * Provider-owned built-in model suppression. + * + * Return `{ suppress: true }` to hide a stale upstream row. Include + * `errorMessage` when OpenClaw should surface a provider-specific hint for + * direct model resolution failures. + */ + suppressBuiltInModel?: ( + ctx: ProviderBuiltInModelSuppressionContext, + ) => ProviderBuiltInModelSuppressionResult | null | undefined; + /** + * Provider-owned final catalog augmentation. + * + * Return extra rows to append to the final catalog after discovery/config + * merging. OpenClaw deduplicates by `provider/id`, so plugins only need to + * describe the desired supplemental rows. + */ + augmentModelCatalog?: ( + ctx: ProviderAugmentModelCatalogContext, + ) => + | Array + | ReadonlyArray + | Promise | ReadonlyArray | null | undefined> + | null + | undefined; wizard?: ProviderPluginWizard; formatApiKey?: (cred: AuthProfileCredential) => string; refreshOAuth?: (cred: OAuthCredential) => Promise; From 74a57ace10bce2f3e80639127101a683c60e456b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:29 -0700 Subject: [PATCH 012/331] refactor(plugins): lazy load provider runtime shims --- src/agents/model-auth.ts | 10 +++++++++- src/agents/model-catalog.ts | 18 ++++++++++++++++-- src/agents/model-suppression.runtime.ts | 1 + src/plugins/provider-runtime.runtime.ts | 4 ++++ src/plugins/provider-runtime.test.ts | 5 +++-- 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 src/agents/model-suppression.runtime.ts create mode 100644 src/plugins/provider-runtime.runtime.ts diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 7064b2fcd01..0616bc41194 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -6,7 +6,6 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -36,6 +35,14 @@ const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK"; const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID"; const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY"; const AWS_PROFILE_ENV = "AWS_PROFILE"; +let providerRuntimePromise: + | Promise + | undefined; + +function loadProviderRuntime() { + providerRuntimePromise ??= import("../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} function resolveProviderConfig( cfg: OpenClawConfig | undefined, @@ -359,6 +366,7 @@ export async function resolveApiKeyForProvider(params: { return resolveAwsSdkAuthInfo(); } + const { buildProviderMissingAuthMessageWithPlugin } = await loadProviderRuntime(); const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({ provider, config: cfg, diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 4274333a518..983150f8d36 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,8 +1,6 @@ import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; -import { shouldSuppressBuiltInModel } from "./model-suppression.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; const log = createSubsystemLogger("model-catalog"); @@ -33,9 +31,23 @@ let modelCatalogPromise: Promise | null = null; let hasLoggedModelCatalogError = false; const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); let importPiSdk = defaultImportPiSdk; +let providerRuntimePromise: + | Promise + | undefined; +let modelSuppressionPromise: Promise | undefined; const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); +function loadProviderRuntime() { + providerRuntimePromise ??= import("../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} + +function loadModelSuppression() { + modelSuppressionPromise ??= import("./model-suppression.runtime.js"); + return modelSuppressionPromise; +} + function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { if (!Array.isArray(input)) { return undefined; @@ -160,6 +172,8 @@ export async function loadModelCatalog(params?: { // will keep failing until restart). const piSdk = await importPiSdk(); const agentDir = resolveOpenClawAgentDir(); + const [{ shouldSuppressBuiltInModel }, { augmentModelCatalogWithProviderPlugins }] = + await Promise.all([loadModelSuppression(), loadProviderRuntime()]); const { join } = await import("node:path"); const authStorage = piSdk.discoverAuthStorage(agentDir); const registry = new (piSdk.ModelRegistry as unknown as { diff --git a/src/agents/model-suppression.runtime.ts b/src/agents/model-suppression.runtime.ts new file mode 100644 index 00000000000..472a662b810 --- /dev/null +++ b/src/agents/model-suppression.runtime.ts @@ -0,0 +1 @@ +export { shouldSuppressBuiltInModel } from "./model-suppression.js"; diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts new file mode 100644 index 00000000000..34a46e1bdac --- /dev/null +++ b/src/plugins/provider-runtime.runtime.ts @@ -0,0 +1,4 @@ +export { + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, +} from "./provider-runtime.js"; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index af5066b5453..24bd47a915f 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -81,8 +81,9 @@ describe("provider-runtime", () => { displayName: "Demo", windows: [{ label: "Day", usedPercent: 25 }], })); - resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { - if (params?.onlyPluginIds?.includes("openai")) { + resolvePluginProvidersMock.mockImplementation((params: unknown) => { + const scopedParams = params as { onlyPluginIds?: string[] } | undefined; + if (scopedParams?.onlyPluginIds?.includes("openai")) { return [ { id: "openai", From 9c89a74f84c5c5b1811cb4a1c38d3bd1f4d330e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:33 -0700 Subject: [PATCH 013/331] perf(cli): trim help startup imports --- scripts/check-cli-startup-memory.mjs | 63 ++++++--- src/cli/banner-config-lite.ts | 24 ++++ src/cli/banner.test.ts | 22 ++- src/cli/banner.ts | 9 +- src/cli/program/command-registry.ts | 41 ++---- src/cli/program/core-command-descriptors.ts | 104 ++++++++++++++ src/cli/program/help.ts | 4 +- src/cli/program/register.subclis.ts | 20 +-- src/cli/program/root-help.ts | 4 +- src/cli/program/subcli-descriptors.ts | 144 ++++++++++++++++++++ 10 files changed, 350 insertions(+), 85 deletions(-) create mode 100644 src/cli/banner-config-lite.ts create mode 100644 src/cli/program/core-command-descriptors.ts create mode 100644 src/cli/program/subcli-descriptors.ts diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index dbf666e1bfb..1b17e28ceea 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -15,6 +15,21 @@ if (!isLinux && !isMac) { const repoRoot = process.cwd(); const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-")); +const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir(); +const rssHookPath = path.join(tmpHome, "measure-rss.mjs"); +const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +writeFileSync( + rssHookPath, + [ + "process.on('exit', () => {", + " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", + ` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`, + "});", + "", + ].join("\n"), + "utf8", +); const DEFAULT_LIMITS_MB = { help: 500, @@ -26,13 +41,13 @@ const cases = [ { id: "help", label: "--help", - args: ["node", "openclaw.mjs", "--help"], + args: ["openclaw.mjs", "--help"], limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help), }, { id: "statusJson", label: "status --json", - args: ["node", "openclaw.mjs", "status", "--json"], + args: ["openclaw.mjs", "status", "--json"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson, ), @@ -40,7 +55,7 @@ const cases = [ { id: "gatewayStatus", label: "gateway status", - args: ["node", "openclaw.mjs", "gateway", "status"], + args: ["openclaw.mjs", "gateway", "status"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus, ), @@ -48,30 +63,44 @@ const cases = [ ]; function parseMaxRssMb(stderr) { - if (isLinux) { - const match = stderr.match(/^\s*Maximum resident set size \(kbytes\):\s*(\d+)\s*$/im); - if (!match) { - return null; - } - return Number(match[1]) / 1024; - } - const match = stderr.match(/^\s*(\d+)\s+maximum resident set size\s*$/im); + const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); if (!match) { return null; } - return Number(match[1]) / (1024 * 1024); + return Number(match[1]) / 1024; } -function runCase(testCase) { +function buildBenchEnv() { const env = { - ...process.env, HOME: tmpHome, + USERPROFILE: tmpHome, XDG_CONFIG_HOME: path.join(tmpHome, ".config"), XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + PATH: process.env.PATH ?? "", + TMPDIR: tmpDir, + TEMP: tmpDir, + TMP: tmpDir, + LANG: process.env.LANG ?? "C.UTF-8", + TERM: process.env.TERM ?? "dumb", }; - const timeArgs = isLinux ? ["-v", ...testCase.args] : ["-l", ...testCase.args]; - const result = spawnSync("/usr/bin/time", timeArgs, { + + if (process.env.LC_ALL) { + env.LC_ALL = process.env.LC_ALL; + } + if (process.env.CI) { + env.CI = process.env.CI; + } + if (process.env.NODE_DISABLE_COMPILE_CACHE) { + env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE; + } + + return env; +} + +function runCase(testCase) { + const env = buildBenchEnv(); + const result = spawnSync(process.execPath, ["--import", rssHookPath, ...testCase.args], { cwd: repoRoot, env, encoding: "utf8", diff --git a/src/cli/banner-config-lite.ts b/src/cli/banner-config-lite.ts new file mode 100644 index 00000000000..f402b7c61b9 --- /dev/null +++ b/src/cli/banner-config-lite.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import JSON5 from "json5"; +import { resolveConfigPath } from "../config/paths.js"; +import type { TaglineMode } from "./tagline.js"; + +function parseTaglineMode(value: unknown): TaglineMode | undefined { + if (value === "random" || value === "default" || value === "off") { + return value; + } + return undefined; +} + +export function readCliBannerTaglineMode( + env: NodeJS.ProcessEnv = process.env, +): TaglineMode | undefined { + try { + const configPath = resolveConfigPath(env); + const raw = fs.readFileSync(configPath, "utf8"); + const parsed: { cli?: { banner?: { taglineMode?: unknown } } } = JSON5.parse(raw); + return parseTaglineMode(parsed.cli?.banner?.taglineMode); + } catch { + return undefined; + } +} diff --git a/src/cli/banner.test.ts b/src/cli/banner.test.ts index 93e47a750d2..722a574f49f 100644 --- a/src/cli/banner.test.ts +++ b/src/cli/banner.test.ts @@ -1,9 +1,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const loadConfigMock = vi.fn(); +const readCliBannerTaglineModeMock = vi.fn(); -vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, +vi.mock("./banner-config-lite.js", () => ({ + readCliBannerTaglineMode: readCliBannerTaglineModeMock, })); let formatCliBannerLine: typeof import("./banner.js").formatCliBannerLine; @@ -13,15 +13,13 @@ beforeAll(async () => { }); beforeEach(() => { - loadConfigMock.mockReset(); - loadConfigMock.mockReturnValue({}); + readCliBannerTaglineModeMock.mockReset(); + readCliBannerTaglineModeMock.mockReturnValue(undefined); }); describe("formatCliBannerLine", () => { it("hides tagline text when cli.banner.taglineMode is off", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -32,9 +30,7 @@ describe("formatCliBannerLine", () => { }); it("uses default tagline when cli.banner.taglineMode is default", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "default" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("default"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -45,9 +41,7 @@ describe("formatCliBannerLine", () => { }); it("prefers explicit tagline mode over config", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 07bc16abfa0..17487d58904 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -1,8 +1,8 @@ -import { loadConfig } from "../config/config.js"; import { resolveCommitHash } from "../infra/git-commit.js"; import { visibleWidth } from "../terminal/ansi.js"; import { isRich, theme } from "../terminal/theme.js"; import { hasRootVersionAlias } from "./argv.js"; +import { readCliBannerTaglineMode } from "./banner-config-lite.js"; import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js"; type BannerOptions = TaglineOptions & { @@ -48,12 +48,7 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { if (explicit) { return explicit; } - try { - return parseTaglineMode(loadConfig().cli?.banner?.taglineMode); - } catch { - // Fall back to default random behavior when config is missing/invalid. - return undefined; - } + return readCliBannerTaglineMode(options.env); } export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index ad468878aeb..4b39b1d94a9 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -3,8 +3,15 @@ import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommandByName } from "./command-tree.js"; import type { ProgramContext } from "./context.js"; +import { + type CoreCliCommandDescriptor, + getCoreCliCommandDescriptors, + getCoreCliCommandsWithSubcommands, +} from "./core-command-descriptors.js"; import { registerSubCliCommands } from "./register.subclis.js"; +export { getCoreCliCommandDescriptors, getCoreCliCommandsWithSubcommands }; + type CommandRegisterParams = { program: Command; ctx: ProgramContext; @@ -16,12 +23,6 @@ export type CommandRegistration = { register: (params: CommandRegisterParams) => void; }; -type CoreCliCommandDescriptor = { - name: string; - description: string; - hasSubcommands: boolean; -}; - type CoreCliEntry = { commands: CoreCliCommandDescriptor[]; register: (params: CommandRegisterParams) => Promise | void; @@ -217,34 +218,8 @@ const coreEntries: CoreCliEntry[] = [ }, ]; -function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescriptor) => boolean) { - const seen = new Set(); - const names: string[] = []; - for (const entry of coreEntries) { - for (const command of entry.commands) { - if (predicate && !predicate(command)) { - continue; - } - if (seen.has(command.name)) { - continue; - } - seen.add(command.name); - names.push(command.name); - } - } - return names; -} - -export function getCoreCliCommandDescriptors(): ReadonlyArray { - return coreEntries.flatMap((entry) => entry.commands); -} - export function getCoreCliCommandNames(): string[] { - return collectCoreCliCommandNames(); -} - -export function getCoreCliCommandsWithSubcommands(): string[] { - return collectCoreCliCommandNames((command) => command.hasSubcommands); + return getCoreCliCommandDescriptors().map((command) => command.name); } function removeEntryCommands(program: Command, entry: CoreCliEntry) { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts new file mode 100644 index 00000000000..6cad819a1dc --- /dev/null +++ b/src/cli/program/core-command-descriptors.ts @@ -0,0 +1,104 @@ +export type CoreCliCommandDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const CORE_CLI_COMMAND_DESCRIPTORS = [ + { + name: "setup", + description: "Initialize local config and agent workspace", + hasSubcommands: false, + }, + { + name: "onboard", + description: "Interactive onboarding wizard for gateway, workspace, and skills", + hasSubcommands: false, + }, + { + name: "configure", + description: "Interactive setup wizard for credentials, channels, gateway, and agent defaults", + hasSubcommands: false, + }, + { + name: "config", + description: + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", + hasSubcommands: true, + }, + { + name: "backup", + description: "Create and verify local backup archives for OpenClaw state", + hasSubcommands: true, + }, + { + name: "doctor", + description: "Health checks + quick fixes for the gateway and channels", + hasSubcommands: false, + }, + { + name: "dashboard", + description: "Open the Control UI with your current token", + hasSubcommands: false, + }, + { + name: "reset", + description: "Reset local config/state (keeps the CLI installed)", + hasSubcommands: false, + }, + { + name: "uninstall", + description: "Uninstall the gateway service + local data (CLI remains)", + hasSubcommands: false, + }, + { + name: "message", + description: "Send, read, and manage messages", + hasSubcommands: true, + }, + { + name: "memory", + description: "Search and reindex memory files", + hasSubcommands: true, + }, + { + name: "agent", + description: "Run one agent turn via the Gateway", + hasSubcommands: false, + }, + { + name: "agents", + description: "Manage isolated agents (workspaces, auth, routing)", + hasSubcommands: true, + }, + { + name: "status", + description: "Show channel health and recent session recipients", + hasSubcommands: false, + }, + { + name: "health", + description: "Fetch health from the running gateway", + hasSubcommands: false, + }, + { + name: "sessions", + description: "List stored conversation sessions", + hasSubcommands: true, + }, + { + name: "browser", + description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + hasSubcommands: true, + }, +] as const satisfies ReadonlyArray; + +export function getCoreCliCommandDescriptors(): ReadonlyArray { + return CORE_CLI_COMMAND_DESCRIPTORS; +} + +export function getCoreCliCommandsWithSubcommands(): string[] { + return CORE_CLI_COMMAND_DESCRIPTORS.filter((command) => command.hasSubcommands).map( + (command) => command.name, + ); +} diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index c22ea7c8322..fc924cec9d3 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -7,9 +7,9 @@ import { hasFlag, hasRootVersionAlias } from "../argv.js"; import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { CLI_LOG_LEVEL_VALUES, parseCliLogLevelOption } from "../log-level-option.js"; -import { getCoreCliCommandsWithSubcommands } from "./command-registry.js"; import type { ProgramContext } from "./context.js"; -import { getSubCliCommandsWithSubcommands } from "./register.subclis.js"; +import { getCoreCliCommandsWithSubcommands } from "./core-command-descriptors.js"; +import { getSubCliCommandsWithSubcommands } from "./subcli-descriptors.js"; const CLI_NAME = resolveCliName(); const CLI_NAME_PATTERN = escapeRegExp(CLI_NAME); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index ad120cc0417..5ace8c10441 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -4,13 +4,17 @@ import { isTruthyEnvValue } from "../../infra/env.js"; import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommand, removeCommandByName } from "./command-tree.js"; +import { + getSubCliCommandsWithSubcommands, + getSubCliEntries as getSubCliEntryDescriptors, + type SubCliDescriptor, +} from "./subcli-descriptors.js"; + +export { getSubCliCommandsWithSubcommands }; type SubCliRegistrar = (program: Command) => Promise | void; -type SubCliEntry = { - name: string; - description: string; - hasSubcommands: boolean; +type SubCliEntry = SubCliDescriptor & { register: SubCliRegistrar; }; @@ -309,12 +313,8 @@ const entries: SubCliEntry[] = [ }, ]; -export function getSubCliEntries(): SubCliEntry[] { - return entries; -} - -export function getSubCliCommandsWithSubcommands(): string[] { - return entries.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +export function getSubCliEntries(): ReadonlyArray { + return getSubCliEntryDescriptors(); } export async function registerSubCliByName(program: Command, name: string): Promise { diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index b80302e9818..500dbe3b039 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,8 +1,8 @@ import { Command } from "commander"; import { VERSION } from "../../version.js"; -import { getCoreCliCommandDescriptors } from "./command-registry.js"; +import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; -import { getSubCliEntries } from "./register.subclis.js"; +import { getSubCliEntries } from "./subcli-descriptors.js"; function buildRootHelpProgram(): Command { const program = new Command(); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts new file mode 100644 index 00000000000..4011e706b2b --- /dev/null +++ b/src/cli/program/subcli-descriptors.ts @@ -0,0 +1,144 @@ +export type SubCliDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const SUB_CLI_DESCRIPTORS = [ + { name: "acp", description: "Agent Control Protocol tools", hasSubcommands: true }, + { + name: "gateway", + description: "Run, inspect, and query the WebSocket Gateway", + hasSubcommands: true, + }, + { name: "daemon", description: "Gateway service (legacy alias)", hasSubcommands: true }, + { name: "logs", description: "Tail gateway file logs via RPC", hasSubcommands: false }, + { + name: "system", + description: "System events, heartbeat, and presence", + hasSubcommands: true, + }, + { + name: "models", + description: "Discover, scan, and configure models", + hasSubcommands: true, + }, + { + name: "approvals", + description: "Manage exec approvals (gateway or node host)", + hasSubcommands: true, + }, + { + name: "nodes", + description: "Manage gateway-owned node pairing and node commands", + hasSubcommands: true, + }, + { + name: "devices", + description: "Device pairing + token management", + hasSubcommands: true, + }, + { + name: "node", + description: "Run and manage the headless node host service", + hasSubcommands: true, + }, + { + name: "sandbox", + description: "Manage sandbox containers for agent isolation", + hasSubcommands: true, + }, + { + name: "tui", + description: "Open a terminal UI connected to the Gateway", + hasSubcommands: false, + }, + { + name: "cron", + description: "Manage cron jobs via the Gateway scheduler", + hasSubcommands: true, + }, + { + name: "dns", + description: "DNS helpers for wide-area discovery (Tailscale + CoreDNS)", + hasSubcommands: true, + }, + { + name: "docs", + description: "Search the live OpenClaw docs", + hasSubcommands: false, + }, + { + name: "hooks", + description: "Manage internal agent hooks", + hasSubcommands: true, + }, + { + name: "webhooks", + description: "Webhook helpers and integrations", + hasSubcommands: true, + }, + { + name: "qr", + description: "Generate iOS pairing QR/setup code", + hasSubcommands: false, + }, + { + name: "clawbot", + description: "Legacy clawbot command aliases", + hasSubcommands: true, + }, + { + name: "pairing", + description: "Secure DM pairing (approve inbound requests)", + hasSubcommands: true, + }, + { + name: "plugins", + description: "Manage OpenClaw plugins and extensions", + hasSubcommands: true, + }, + { + name: "channels", + description: "Manage connected chat channels (Telegram, Discord, etc.)", + hasSubcommands: true, + }, + { + name: "directory", + description: "Lookup contact and group IDs (self, peers, groups) for supported chat channels", + hasSubcommands: true, + }, + { + name: "security", + description: "Security tools and local config audits", + hasSubcommands: true, + }, + { + name: "secrets", + description: "Secrets runtime reload controls", + hasSubcommands: true, + }, + { + name: "skills", + description: "List and inspect available skills", + hasSubcommands: true, + }, + { + name: "update", + description: "Update OpenClaw and inspect update channel status", + hasSubcommands: true, + }, + { + name: "completion", + description: "Generate shell completion script", + hasSubcommands: false, + }, +] as const satisfies ReadonlyArray; + +export function getSubCliEntries(): ReadonlyArray { + return SUB_CLI_DESCRIPTORS; +} + +export function getSubCliCommandsWithSubcommands(): string[] { + return SUB_CLI_DESCRIPTORS.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +} From 83ee5c03285317725d56f0db00101e2c124be285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:37 -0700 Subject: [PATCH 014/331] perf(status): defer heavy startup loading --- src/channels/config-presence.ts | 94 ++++++++++++++++ src/cli/program/preaction.test.ts | 14 +++ src/cli/program/preaction.ts | 12 ++- src/cli/program/routes.ts | 6 +- src/cli/route.test.ts | 4 +- src/commands/status.command.ts | 14 ++- src/commands/status.scan.test.ts | 173 ++++++++++++++++++++++++++++++ src/commands/status.scan.ts | 12 +++ src/commands/status.summary.ts | 16 +-- src/commands/status.test.ts | 2 +- src/security/audit.ts | 3 +- 11 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 src/channels/config-presence.ts diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts new file mode 100644 index 00000000000..792aa545a54 --- /dev/null +++ b/src/channels/config-presence.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; + +const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +const CHANNEL_ENV_PREFIXES = [ + "BLUEBUBBLES_", + "DISCORD_", + "GOOGLECHAT_", + "IRC_", + "LINE_", + "MATRIX_", + "MSTEAMS_", + "SIGNAL_", + "SLACK_", + "TELEGRAM_", + "WHATSAPP_", + "ZALOUSER_", + "ZALO_", +] as const; + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordHasKeys(value: unknown): boolean { + return isRecord(value) && Object.keys(value).length > 0; +} + +function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { + try { + const oauthDir = resolveOAuthDir(env); + const legacyCreds = path.join(oauthDir, "creds.json"); + if (fs.existsSync(legacyCreds)) { + return true; + } + + const accountsRoot = path.join(oauthDir, "whatsapp"); + const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json"); + if (fs.existsSync(defaultCreds)) { + return true; + } + + const entries = fs.readdirSync(accountsRoot, { withFileTypes: true }); + return entries.some((entry) => { + if (!entry.isDirectory()) { + return false; + } + return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json")); + }); + } catch { + return false; + } +} + +function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + if ( + CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + key === "TELEGRAM_BOT_TOKEN" + ) { + return true; + } + } + return hasWhatsAppAuthState(env); +} + +export function hasPotentialConfiguredChannels( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + return true; + } + } + } + return hasEnvConfiguredChannel(env); +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2a1367870c6..2376e97100f 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -190,6 +190,19 @@ describe("registerPreActionHooks", () => { }); it("applies --json stdout suppression only for explicit JSON output commands", async () => { + await runPreAction({ + parseArgv: ["status"], + processArgv: ["node", "openclaw", "status", "--json"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["status"], + suppressDoctorStdout: true, + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); await runPreAction({ parseArgv: ["update", "status", "--json"], processArgv: ["node", "openclaw", "update", "status", "--json"], @@ -200,6 +213,7 @@ describe("registerPreActionHooks", () => { commandPath: ["update", "status"], suppressDoctorStdout: true, }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); vi.clearAllMocks(); await runPreAction({ diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index ccd84e3201e..19659f97c7e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -71,6 +71,16 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; } +function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { + if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + return false; + } + if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + return false; + } + return true; +} + function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -138,7 +148,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access - if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); } diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index cea5fcb8138..52e0d8f8446 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -34,9 +34,9 @@ const routeHealth: RouteSpec = { const routeStatus: RouteSpec = { match: (path) => path[0] === "status", - // Status runs security audit with channel checks in both text and JSON output, - // so plugin registry must be ready for consistent findings. - loadPlugins: true, + // `status --json` can defer channel plugin loading until config/env inspection + // proves it is needed, which keeps the fast-path startup lightweight. + loadPlugins: (argv) => !hasFlag(argv, "--json"), run: async (argv) => { const json = hasFlag(argv, "--json"); const deep = hasFlag(argv, "--deep"); diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index 93516906ad0..9e7c6c7c110 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -37,7 +37,7 @@ describe("tryRouteCli", () => { vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); findRoutedCommandMock.mockReturnValue({ - loadPlugins: true, + loadPlugins: (argv: string[]) => !argv.includes("--json"), run: runRouteMock, }); }); @@ -59,7 +59,7 @@ describe("tryRouteCli", () => { suppressDoctorStdout: true, }), ); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); it("does not pass suppressDoctorStdout for routed non-json commands", async () => { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 7e68424c5a9..92702bac66e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -5,7 +5,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; -import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { formatGitInstallLabel } from "../infra/update-check.js"; import { @@ -37,6 +36,13 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; @@ -138,7 +144,10 @@ export async function statusCommand( indeterminate: true, enabled: opts.json !== true, }, - async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + async () => { + const { loadProviderUsageSummary } = await loadProviderUsage(); + return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }); + }, ) : undefined; const health: HealthSummary | undefined = opts.deep @@ -658,6 +667,7 @@ export async function statusCommand( } if (usage) { + const { formatUsageReportLines } = await loadProviderUsage(); runtime.log(""); runtime.log(theme.heading("Usage")); for (const line of formatUsageReportLines(usage)) { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 6592b84c864..9d3399997bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), })); vi.mock("../cli/progress.js", () => ({ @@ -70,6 +71,10 @@ vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); +vi.mock("../cli/plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, +})); + import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { @@ -135,4 +140,172 @@ describe("scanStatus", () => { }), ); }); + + it("skips channel plugin preload for status --json with no channel config", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + }); + + it("preloads channel plugins for status --json when channel config exists", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); + + it("preloads channel plugins for status --json when channel auth is env-only", async () => { + const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; + process.env.MATRIX_ACCESS_TOKEN = "token"; + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + try { + await scanStatus({ json: true }, {} as never); + } finally { + if (prevMatrixToken === undefined) { + delete process.env.MATRIX_ACCESS_TOKEN; + } else { + process.env.MATRIX_ACCESS_TOKEN = prevMatrixToken; + } + } + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 38e15e6417b..0de308f17f2 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,3 +1,4 @@ +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; @@ -46,6 +47,13 @@ type GatewayProbeSnapshot = { gatewayProbe: Awaited> | null; }; +let pluginRegistryModulePromise: Promise | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -191,6 +199,10 @@ async function scanStatusJsonFast(opts: { targetIds: getStatusCommandSecretTargetIds(), mode: "summary", }); + if (hasPotentialConfiguredChannels(cfg)) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + ensurePluginRegistryLoaded({ scope: "channels" }); + } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const updateTimeoutMs = opts.all ? 6500 : 2500; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b84bada07ff..e1347a90b5a 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,6 +1,7 @@ import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { @@ -89,7 +90,8 @@ export async function getStatusSummary( ): Promise { const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); - const linkContext = await resolveLinkChannelContext(cfg); + const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); + const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null; const agentList = listAgentsForGateway(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); @@ -100,11 +102,13 @@ export async function getStatusSummary( everyMs: summary.everyMs, } satisfies HeartbeatStatus; }); - const channelSummary = await buildChannelSummary(cfg, { - colorize: true, - includeAllowFrom: true, - sourceConfig: options.sourceConfig, - }); + const channelSummary = needsChannelPlugins + ? await buildChannelSummary(cfg, { + colorize: true, + includeAllowFrom: true, + sourceConfig: options.sourceConfig, + }) + : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index c40693302ac..5cc71b6e950 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -398,7 +398,7 @@ describe("statusCommand", () => { it("prints JSON when requested", async () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); - expect(payload.linkChannel.linked).toBe(true); + expect(payload.linkChannel).toBeUndefined(); expect(payload.memory.agentId).toBe("main"); expect(payload.memoryPlugin.enabled).toBe(true); expect(payload.memoryPlugin.slot).toBe("memory-core"); diff --git a/src/security/audit.ts b/src/security/audit.ts index 113ec2bd067..dbbfb9651be 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -5,6 +5,7 @@ import { execDockerRaw } from "../agents/sandbox/docker.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; @@ -1226,7 +1227,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 18:20:42 -0700 Subject: [PATCH 015/331] fix(matrix): assert outbound runtime hooks --- extensions/matrix/src/channel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 0522590356a..a6a33a7f627 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -379,7 +379,7 @@ export const matrixPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), + chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params), From 71a69e533791cc943fe2210daf64f35de86b54f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:22:48 -0700 Subject: [PATCH 016/331] refactor: extend setup wizard account resolution --- src/channels/plugins/setup-wizard.ts | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index f71d1802aa3..9f4f1fdb5cc 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -248,6 +248,15 @@ export type ChannelSetupWizard = { status: ChannelSetupWizardStatus; introNote?: ChannelSetupWizardNote; envShortcut?: ChannelSetupWizardEnvShortcut; + resolveAccountIdForConfigure?: (params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + options?: ChannelOnboardingConfigureContext["options"]; + accountOverride?: string; + shouldPromptAccountIds: boolean; + listAccountIds: ChannelSetupWizardPlugin["config"]["listAccountIds"]; + defaultAccountId: string; + }) => string | Promise; resolveShouldPromptAccountIds?: (params: { cfg: OpenClawConfig; options?: ChannelOnboardingConfigureContext["options"]; @@ -416,15 +425,25 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { options, shouldPromptAccountIds, }) ?? shouldPromptAccountIds; - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: plugin.meta.label, - accountOverride: accountOverrides[plugin.id], - shouldPromptAccountIds: resolvedShouldPromptAccountIds, - listAccountIds: plugin.config.listAccountIds, - defaultAccountId, - }); + const accountId = await (wizard.resolveAccountIdForConfigure + ? wizard.resolveAccountIdForConfigure({ + cfg, + prompter, + options, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds: resolvedShouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + }) + : resolveAccountIdForConfigure({ + cfg, + prompter, + label: plugin.meta.label, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds: resolvedShouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + })); let next = cfg; let credentialValues = collectCredentialValues({ From 40be12db966a37abbffbffa65ecd482ef95fb9f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:22:57 -0700 Subject: [PATCH 017/331] refactor: move feishu zalo zalouser to setup wizard --- extensions/feishu/src/channel.ts | 63 +-- .../feishu/src/onboarding.status.test.ts | 12 +- extensions/feishu/src/onboarding.test.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 371 ++++++++++-------- extensions/zalo/src/channel.ts | 56 +-- extensions/zalo/src/onboarding.status.test.ts | 12 +- extensions/zalo/src/setup-surface.test.ts | 60 +++ .../src/{onboarding.ts => setup-surface.ts} | 210 ++++++---- extensions/zalouser/src/channel.ts | 40 +- extensions/zalouser/src/setup-surface.test.ts | 86 ++++ .../src/{onboarding.ts => setup-surface.ts} | 283 +++++++------ src/plugin-sdk/feishu.ts | 8 +- src/plugin-sdk/zalo.ts | 6 +- src/plugin-sdk/zalouser.ts | 10 +- 14 files changed, 675 insertions(+), 560 deletions(-) rename extensions/feishu/src/{onboarding.ts => setup-surface.ts} (62%) create mode 100644 extensions/zalo/src/setup-surface.test.ts rename extensions/zalo/src/{onboarding.ts => setup-surface.ts} (65%) create mode 100644 extensions/zalouser/src/setup-surface.test.ts rename extensions/zalouser/src/{onboarding.ts => setup-surface.ts} (57%) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ecfd27194b7..7d8560d5182 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,6 +25,7 @@ import { FeishuConfigSchema } from "./config-schema.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; +import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; @@ -43,44 +44,6 @@ async function loadFeishuChannelRuntime() { return await import("./channel.runtime.js"); } -const feishuOnboarding = { - channel: "feishu", - getStatus: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx), - configure: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx), - dmPolicy: { - label: "Feishu", - channel: "feishu", - policyKey: "channels.feishu.dmPolicy", - allowFromKey: "channels.feishu.allowFrom", - getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - dmPolicy: policy, - }, - }, - }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({ - cfg, - prompter, - accountId, - }), - }, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { ...cfg.channels?.feishu, enabled: false }, - }, - }), -} satisfies ChannelPlugin["onboarding"]; - function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -429,28 +392,8 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg, accountId }) => { - const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; - - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - }, - }, - }; - } - - return setFeishuNamedAccountEnabled(cfg, accountId, true); - }, - }, - onboarding: feishuOnboarding, + setup: feishuSetupAdapter, + setupWizard: feishuSetupWizard, messaging: { normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined, targetResolver: { diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index eda2bafa242..4f3b853a1e2 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it } from "vitest"; -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { feishuPlugin } from "./channel.js"; -describe("feishu onboarding status", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard status", () => { it("treats SecretRef appSecret as configured when appId is present", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index d3ace4faae0..2a444964442 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), })); -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { feishuPlugin } from "./channel.js"; const baseConfigureContext = { runtime: {} as never, @@ -42,7 +43,7 @@ async function withEnvVars(values: Record, run: () = } async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) { - return await feishuOnboardingAdapter.getStatus({ + return await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { @@ -55,7 +56,12 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -describe("feishuOnboardingAdapter.configure", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard", () => { it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi .fn() @@ -73,7 +79,7 @@ describe("feishuOnboardingAdapter.configure", () => { } as never; await expect( - feishuOnboardingAdapter.configure({ + feishuConfigureAdapter.configure({ cfg: { channels: { feishu: { @@ -89,9 +95,9 @@ describe("feishuOnboardingAdapter.configure", () => { }); }); -describe("feishuOnboardingAdapter.getStatus", () => { +describe("feishu setup wizard status", () => { it("does not fallback to top-level appId when account explicitly sets empty appId", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/setup-surface.ts similarity index 62% rename from extensions/feishu/src/onboarding.ts rename to extensions/feishu/src/setup-surface.ts index 24d3bbcc413..1191a08e4e9 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,24 +1,22 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - ClawdbotConfig, - DmPolicy, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/feishu"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/feishu"; -import { resolveFeishuCredentials } from "./accounts.js"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import type { FeishuConfig } from "./types.js"; @@ -32,26 +30,117 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "feishu", - dmPolicy, - }) as ClawdbotConfig; +function setFeishuNamedAccountEnabled( + cfg: OpenClawConfig, + accountId: string, + enabled: boolean, +): OpenClawConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; } -function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { +function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as OpenClawConfig; +} + +function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "feishu", + channel, allowFrom, - }) as ClawdbotConfig; + }) as OpenClawConfig; +} + +function setFeishuGroupPolicy( + cfg: OpenClawConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setTopLevelChannelGroupPolicy({ + cfg, + channel, + groupPolicy, + enabled: true, + }) as OpenClawConfig; +} + +function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + groupAllowFrom, + }, + }, + }; +} + +function isFeishuConfigured(cfg: OpenClawConfig): boolean { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + + const isAppIdConfigured = (value: unknown): boolean => { + const asString = normalizeString(value); + if (asString) { + return true; + } + if (!value || typeof value !== "object") { + return false; + } + const rec = value as Record; + const source = normalizeString(rec.source)?.toLowerCase(); + const id = normalizeString(rec.id); + if (source === "env" && id) { + return Boolean(normalizeString(process.env[id])); + } + return hasConfiguredSecretInput(value); + }; + + const topLevelConfigured = Boolean( + isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), + ); + + const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { + if (!account || typeof account !== "object") { + return false; + } + const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); + const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); + const accountAppIdConfigured = hasOwnAppId + ? isAppIdConfigured((account as Record).appId) + : isAppIdConfigured(feishuCfg?.appId); + const accountSecretConfigured = hasOwnAppSecret + ? hasConfiguredSecretInput((account as Record).appSecret) + : hasConfiguredSecretInput(feishuCfg?.appSecret); + return Boolean(accountAppIdConfigured && accountSecretConfigured); + }); + + return topLevelConfigured || accountConfigured; } async function promptFeishuAllowFrom(params: { - cfg: ClawdbotConfig; - prompter: WizardPrompter; -}): Promise { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; +}): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( [ @@ -82,7 +171,9 @@ async function promptFeishuAllowFrom(params: { } } -async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { +async function noteFeishuCredentialHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Go to Feishu Open Platform (open.feishu.cn)", @@ -98,131 +189,82 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise } async function promptFeishuAppId(params: { - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; initialValue?: string; }): Promise { - const appId = String( + return String( await params.prompter.text({ message: "Enter Feishu App ID", initialValue: params.initialValue, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return appId; } -function setFeishuGroupPolicy( - cfg: ClawdbotConfig, - groupPolicy: "open" | "allowlist" | "disabled", -): ClawdbotConfig { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "feishu", - groupPolicy, - enabled: true, - }) as ClawdbotConfig; -} - -function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - groupAllowFrom, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const feishuDmPolicy: ChannelOnboardingDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, }; -export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - - const isAppIdConfigured = (value: unknown): boolean => { - const asString = normalizeString(value); - if (asString) { - return true; - } - if (!value || typeof value !== "object") { - return false; - } - const rec = value as Record; - const source = normalizeString(rec.source)?.toLowerCase(); - const id = normalizeString(rec.id); - if (source === "env" && id) { - return Boolean(normalizeString(process.env[id])); - } - return hasConfiguredSecretInput(value); - }; - - const topLevelConfigured = Boolean( - isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), - ); - - const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { - if (!account || typeof account !== "object") { - return false; - } - const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); - const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); - const accountAppIdConfigured = hasOwnAppId - ? isAppIdConfigured((account as Record).appId) - : isAppIdConfigured(feishuCfg?.appId); - const accountSecretConfigured = hasOwnAppSecret - ? hasConfiguredSecretInput((account as Record).appSecret) - : hasConfiguredSecretInput(feishuCfg?.appSecret); - return Boolean(accountAppIdConfigured && accountSecretConfigured); - }); - - const configured = topLevelConfigured || accountConfigured; - const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { - allowUnresolvedSecretRef: true, - }); - - // Try to probe if configured - let probeResult = null; - if (configured && resolvedCredentials) { - try { - probeResult = await probeFeishu(resolvedCredentials); - } catch { - // Ignore probe errors - } +export const feishuSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; } - - const statusLines: string[] = []; - if (!configured) { - statusLines.push("Feishu: needs app credentials"); - } else if (probeResult?.ok) { - statusLines.push( - `Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`, - ); - } else { - statusLines.push("Feishu: configured (connection not verified)"); - } - - return { - channel, - configured, - statusLines, - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + return setFeishuNamedAccountEnabled(cfg, accountId, true); }, +}; - configure: async ({ cfg, prompter }) => { +export const feishuSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg), + resolveStatusLines: async ({ cfg, configured }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { + allowUnresolvedSecretRef: true, + }); + let probeResult = null; + if (configured && resolvedCredentials) { + try { + probeResult = await probeFeishu(resolvedCredentials); + } catch {} + } + if (!configured) { + return ["Feishu: needs app credentials"]; + } + if (probeResult?.ok) { + return [`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`]; + } + return ["Feishu: configured (connection not verified)"]; + }, + }, + credentials: [], + finalize: async ({ cfg, prompter, options }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolved = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, @@ -252,6 +294,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "feishu", credentialLabel: "App Secret", + secretInputMode: options?.secretInputMode, accountConfigured: appSecretPromptState.accountConfigured, canUseEnv: appSecretPromptState.canUseEnv, hasConfigToken: appSecretPromptState.hasConfigToken, @@ -293,7 +336,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; - // Test connection try { const probe = await probeFeishu({ appId, @@ -340,19 +382,17 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) ?.verificationToken; - const verificationTokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentVerificationToken), - hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), - allowEnv: false, - }); const verificationTokenResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "verification token", - accountConfigured: verificationTokenPromptState.accountConfigured, - canUseEnv: verificationTokenPromptState.canUseEnv, - hasConfigToken: verificationTokenPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentVerificationToken), + hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu verification token already configured. Keep it?", inputPrompt: "Enter Feishu verification token", @@ -370,20 +410,19 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; - const encryptKeyPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentEncryptKey), - hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), - allowEnv: false, - }); const encryptKeyResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "encrypt key", - accountConfigured: encryptKeyPromptState.accountConfigured, - canUseEnv: encryptKeyPromptState.canUseEnv, - hasConfigToken: encryptKeyPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentEncryptKey), + hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu encrypt key already configured. Keep it?", inputPrompt: "Enter Feishu encrypt key", @@ -401,6 +440,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; const webhookPath = String( await prompter.text({ @@ -421,7 +461,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }; } - // Domain selection const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; const domain = await prompter.select({ message: "Which Feishu domain?", @@ -431,21 +470,18 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { ], initialValue: currentDomain, }); - if (domain) { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - domain: domain as "feishu" | "lark", - }, + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + domain: domain as "feishu" | "lark", }, - }; - } + }, + }; - // Group policy - const groupPolicy = await prompter.select({ + const groupPolicy = (await prompter.select({ message: "Group chat policy", options: [ { value: "allowlist", label: "Allowlist - only respond in specific groups" }, @@ -453,12 +489,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { { value: "disabled", label: "Disabled - don't respond in groups" }, ], initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", - }); - if (groupPolicy) { - next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); - } + })) as "allowlist" | "open" | "disabled"; + next = setFeishuGroupPolicy(next, groupPolicy); - // Group allowlist if needed if (groupPolicy === "allowlist") { const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; const entry = await prompter.text({ @@ -474,11 +507,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { } } - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + return { cfg: next }; }, - - dmPolicy, - + dmPolicy: feishuDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b374ecfbd63..adba1f8bd93 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -13,8 +13,6 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, @@ -23,9 +21,7 @@ import { deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, - migrateBaseNameToDefaultAccount, listDirectoryUserEntriesFromAllowFrom, - normalizeAccountId, isNumericTargetId, PAIRING_APPROVED_MESSAGE, resolveOutboundMediaUrls, @@ -40,11 +36,11 @@ import { } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; -import { zaloOnboardingAdapter } from "./onboarding.js"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; +import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { @@ -92,7 +88,8 @@ export const zaloDock: ChannelDock = { export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, - onboarding: zaloOnboardingAdapter, + setup: zaloSetupAdapter, + setupWizard: zaloSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -212,53 +209,6 @@ export const zaloPlugin: ChannelPlugin = { }, listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalo", - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalo", - accountId, - patch, - }); - }, - }, pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index fed5ea95f89..4db31735c94 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { zaloOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { zaloPlugin } from "./channel.js"; -describe("zalo onboarding status", () => { +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard status", () => { it("treats SecretRef botToken as configured", async () => { - const status = await zaloOnboardingAdapter.getStatus({ + const status = await zaloConfigureAdapter.getStatus({ cfg: { channels: { zalo: { diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts new file mode 100644 index 00000000000..2353a66e453 --- /dev/null +++ b/extensions/zalo/src/setup-surface.test.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { zaloPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "plaintext") as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard", () => { + it("configures a polling token flow", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Zalo bot token") { + return "12345689:abc-xyz"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Use webhook mode for Zalo?") { + return false; + } + return false; + }), + }); + + const runtime: RuntimeEnv = createRuntimeEnv(); + + const result = await zaloConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { secretInputMode: "plaintext" }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalo?.enabled).toBe(true); + expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz"); + expect(result.cfg.channels?.zalo?.webhookUrl).toBeUndefined(); + }); +}); diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/setup-surface.ts similarity index 65% rename from extensions/zalo/src/onboarding.ts rename to extensions/zalo/src/setup-surface.ts index 4c6f7cbe4de..643c2f6ff76 100644 --- a/extensions/zalo/src/onboarding.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,21 +1,23 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/zalo"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, mergeAllowFromEntries, - normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalo"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; const channel = "zalo" as const; @@ -28,7 +30,7 @@ function setZaloDmPolicy( ) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalo", + channel, dmPolicy, }) as OpenClawConfig; } @@ -108,14 +110,16 @@ function setZaloUpdateMode( } as OpenClawConfig; } -async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { +async function noteZaloTokenHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Open Zalo Bot Platform: https://bot.zaloplatforms.com", "2) Create a bot and get the token", "3) Token looks like 12345689:abc-xyz", "Tip: you can also set ZALO_BOT_TOKEN in your env.", - "Docs: https://docs.openclaw.ai/channels/zalo", + `Docs: ${formatDocsLink("/channels/zalo", "zalo")}`, ].join("\n"), "Zalo bot token", ); @@ -123,7 +127,7 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -183,76 +187,111 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZaloDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZaloAccountId(cfg); - return promptZaloAllowFrom({ - cfg: cfg, + : resolveDefaultZaloAccountId(cfg as OpenClawConfig); + return await promptZaloAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const configured = listZaloAccountIds(cfg).some((accountId) => { - const account = resolveZaloAccount({ - cfg: cfg, - accountId, - allowUnresolvedSecretRef: true, - }); - return ( - Boolean(account.token) || - hasConfiguredSecretInput(account.config.botToken) || - Boolean(account.config.tokenFile?.trim()) - ); - }); - return { - channel, - configured, - statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg); - const zaloAccountId = await resolveAccountIdForConfigure({ +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo", - accountOverride: accountOverrides.zalo, - shouldPromptAccountIds, - listAccountIds: listZaloAccountIds, - defaultAccountId: defaultZaloAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; +export const zaloSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listZaloAccountIds(cfg).some((accountId) => { + const account = resolveZaloAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); + return ( + Boolean(account.token) || + hasConfiguredSecretInput(account.config.botToken) || + Boolean(account.config.tokenFile?.trim()) + ); + }), + resolveStatusLines: ({ cfg, configured }) => { + void cfg; + return [`Zalo: ${configured ? "configured" : "needs token"}`]; + }, + }, + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => { let next = cfg; const resolvedAccount = resolveZaloAccount({ cfg: next, - accountId: zaloAccountId, + accountId, allowUnresolvedSecretRef: true, }); const accountConfigured = Boolean(resolvedAccount.token); - const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID; + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const hasConfigToken = Boolean( hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile, ); @@ -261,6 +300,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo", credentialLabel: "bot token", + secretInputMode: options?.secretInputMode, accountConfigured, hasConfigToken, allowEnv, @@ -270,43 +310,43 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { inputPrompt: "Enter Zalo bot token", preferredEnvVar: "ZALO_BOT_TOKEN", onMissingConfigured: async () => await noteZaloTokenHelp(prompter), - applyUseEnv: async (cfg) => - zaloAccountId === DEFAULT_ACCOUNT_ID + applyUseEnv: async (currentCfg) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, }, }, } as OpenClawConfig) - : cfg, - applySet: async (cfg, value) => - zaloAccountId === DEFAULT_ACCOUNT_ID + : currentCfg, + applySet: async (currentCfg, value) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, botToken: value, }, }, } as OpenClawConfig) : ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, accounts: { - ...cfg.channels?.zalo?.accounts, - [zaloAccountId]: { - ...cfg.channels?.zalo?.accounts?.[zaloAccountId], + ...currentCfg.channels?.zalo?.accounts, + [accountId]: { + ...currentCfg.channels?.zalo?.accounts?.[accountId], enabled: true, botToken: value, }, @@ -337,11 +377,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { return "/zalo-webhook"; } })(); + let webhookSecretResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), @@ -363,6 +405,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: false, hasConfigToken: false, @@ -386,24 +429,25 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { ).trim(); next = setZaloUpdateMode( next, - zaloAccountId, + accountId, "webhook", webhookUrl, webhookSecret, webhookPath || undefined, ); } else { - next = setZaloUpdateMode(next, zaloAccountId, "polling"); + next = setZaloUpdateMode(next, accountId, "polling"); } if (forceAllowFrom) { next = await promptZaloAllowFrom({ cfg: next, prompter, - accountId: zaloAccountId, + accountId, }); } - return { cfg: next, accountId: zaloAccountId }; + return { cfg: next }; }, + dmPolicy: zaloDmPolicy, }; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 81fce5e3ab9..b7d103e9b6e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -14,8 +14,6 @@ import type { GroupToolPolicyConfig, } from "openclaw/plugin-sdk/zalouser"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildChannelSendResult, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -24,7 +22,6 @@ import { formatAllowFromLowercase, isDangerousNameMatchingEnabled, isNumericTargetId, - migrateBaseNameToDefaultAccount, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, @@ -41,11 +38,11 @@ import { import { ZalouserConfigSchema } from "./config-schema.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; -import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; +import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, @@ -332,7 +329,8 @@ export const zalouserDock: ChannelDock = { export const zalouserPlugin: ChannelPlugin = { id: "zalouser", meta, - onboarding: zalouserOnboardingAdapter, + setup: zalouserSetupAdapter, + setupWizard: zalouserSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -407,38 +405,6 @@ export const zalouserPlugin: ChannelPlugin = { resolveReplyToMode: () => "off", }, actions: zalouserMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalouser", - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalouser", - accountId, - patch: {}, - }); - }, - }, messaging: { normalizeTarget: (raw) => normalizePrefixedTarget(raw), targetResolver: { diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts new file mode 100644 index 00000000000..d28fd8f0ccc --- /dev/null +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -0,0 +1,86 @@ +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; + +vi.mock("./zalo-js.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkZaloAuthenticated: vi.fn(async () => false), + logoutZaloProfile: vi.fn(async () => {}), + startZaloQrLogin: vi.fn(async () => ({ + message: "qr pending", + qrDataUrl: undefined, + })), + waitForZaloQrLogin: vi.fn(async () => ({ + connected: false, + message: "login pending", + })), + resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + }; +}); + +import { zalouserPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zalouserPlugin, + wizard: zalouserPlugin.setupWizard!, +}); + +describe("zalouser setup wizard", () => { + it("enables the account without forcing QR login", async () => { + const runtime = createRuntimeEnv(); + const prompter = createPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + }); +}); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/setup-surface.ts similarity index 57% rename from extensions/zalouser/src/onboarding.ts rename to extensions/zalouser/src/setup-surface.ts index d5b828b6711..b091ed37947 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,19 +1,20 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - WizardPrompter, -} from "openclaw/plugin-sdk/zalouser"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - DEFAULT_ACCOUNT_ID, - formatResolvedUnresolvedNote, mergeAllowFromEntries, - normalizeAccountId, - patchScopedAccountConfig, - promptChannelAccessConfig, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalouser"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, @@ -52,19 +53,42 @@ function setZalouserDmPolicy( ): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalouser", + channel, dmPolicy, }) as OpenClawConfig; } -async function noteZalouserHelp(prompter: WizardPrompter): Promise { +function setZalouserGroupPolicy( + cfg: OpenClawConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); +} + +function setZalouserGroupAllowlist( + cfg: OpenClawConfig, + accountId: string, + groupKeys: string[], +): OpenClawConfig { + const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); +} + +async function noteZalouserHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "Zalo Personal Account login via QR code.", "", "This plugin uses zca-js directly (no external CLI dependency).", "", - "Docs: https://docs.openclaw.ai/channels/zalouser", + `Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`, ].join("\n"), "Zalo Personal Setup", ); @@ -72,7 +96,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -125,94 +149,90 @@ async function promptZalouserAllowFrom(params: { } } -function setZalouserGroupPolicy( - cfg: OpenClawConfig, - accountId: string, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return setZalouserAccountScopedConfig(cfg, accountId, { - groupPolicy, - }); -} - -function setZalouserGroupAllowlist( - cfg: OpenClawConfig, - accountId: string, - groupKeys: string[], -): OpenClawConfig { - const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - return setZalouserAccountScopedConfig(cfg, accountId, { - groups, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZalouserAccountId(cfg); - return promptZalouserAllowFrom({ - cfg, + : resolveDefaultZalouserAccountId(cfg as OpenClawConfig); + return await promptZalouserAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const ids = listZalouserAccountIds(cfg); - let configured = false; - for (const accountId of ids) { - const account = resolveZalouserAccountSync({ cfg, accountId }); - const isAuth = await checkZcaAuthenticated(account.profile); - if (isAuth) { - configured = true; - break; - } - } - return { - channel, - configured, - statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`], - selectionHint: configured ? "recommended · logged in" : "recommended · QR login", - quickstartScore: configured ? 1 : 15, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultAccountId = resolveDefaultZalouserAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo Personal", - accountOverride: accountOverrides.zalouser, - shouldPromptAccountIds, - listAccountIds: listZalouserAccountIds, - defaultAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; +export const zalouserSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "logged in", + unconfiguredLabel: "needs QR login", + configuredHint: "recommended · logged in", + unconfiguredHint: "recommended · QR login", + configuredScore: 1, + unconfiguredScore: 15, + resolveConfigured: async ({ cfg }) => { + const ids = listZalouserAccountIds(cfg); + for (const accountId of ids) { + const account = resolveZalouserAccountSync({ cfg, accountId }); + if (await checkZcaAuthenticated(account.profile)) { + return true; + } + } + return false; + }, + resolveStatusLines: async ({ cfg, configured }) => { + void cfg; + return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`]; + }, + }, + prepare: async ({ cfg, accountId, prompter }) => { let next = cfg; const account = resolveZalouserAccountSync({ cfg: next, accountId }); const alreadyAuthenticated = await checkZcaAuthenticated(account.profile); if (!alreadyAuthenticated) { await noteZalouserHelp(prompter); - const wantsLogin = await prompter.confirm({ message: "Login via QR code now?", initialValue: true, @@ -280,6 +300,56 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { { profile: account.profile, enabled: true }, ); + return { cfg: next }; + }, + credentials: [], + groupAccess: { + label: "Zalo groups", + placeholder: "Family, Work, 123456789", + currentPolicy: ({ cfg, accountId }) => + resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups), + setPolicy: ({ cfg, accountId, policy }) => + setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy), + resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => { + if (entries.length === 0) { + return []; + } + const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }); + try { + const resolved = await resolveZaloGroupsByEntries({ + profile: updatedAccount.profile, + entries, + }); + const resolvedIds = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + const keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await prompter.note(resolution, "Zalo groups"); + } + return keys; + } catch (err) { + await prompter.note( + `Group lookup failed; keeping entries as typed. ${String(err)}`, + "Zalo groups", + ); + return entries.map((entry) => entry.trim()).filter(Boolean); + } + }, + applyAllowlist: ({ cfg, accountId, resolved }) => + setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]), + }, + finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => { + let next = cfg; if (forceAllowFrom) { next = await promptZalouserAllowFrom({ cfg: next, @@ -287,54 +357,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { accountId, }); } - - const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId }); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Zalo groups", - currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(updatedAccount.config.groups ?? {}), - placeholder: "Family, Work, 123456789", - updatePrompt: Boolean(updatedAccount.config.groups), - }); - - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setZalouserGroupPolicy(next, accountId, accessConfig.policy); - } else { - let keys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolved = await resolveZaloGroupsByEntries({ - profile: updatedAccount.profile, - entries: accessConfig.entries, - }); - const resolvedIds = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Zalo groups"); - } - } catch (err) { - await prompter.note( - `Group lookup failed; keeping entries as typed. ${String(err)}`, - "Zalo groups", - ); - } - } - next = setZalouserGroupPolicy(next, accountId, "allowlist"); - next = setZalouserGroupAllowlist(next, accountId, keys); - } - } - - return { cfg: next, accountId }; + return { cfg: next }; }, + dmPolicy: zalouserDmPolicy, }; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 772cde76ff2..65f0773105b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -13,10 +13,6 @@ export { logTypingFailure } from "../channels/logging.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { createActionGate } from "../agents/tools/common.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -66,6 +62,10 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + feishuSetupAdapter, + feishuSetupWizard, +} from "../../extensions/feishu/src/setup-surface.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index e13529f8c42..4323ae4eb6e 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -11,10 +11,6 @@ export { export { listDirectoryUserEntriesFromAllowFrom } from "../channels/plugins/directory-config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -22,7 +18,6 @@ export { promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; @@ -69,6 +64,7 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; +export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 4b8ef88d06d..47fc787570c 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -11,16 +11,10 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { @@ -61,6 +55,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; +export { + zalouserSetupAdapter, + zalouserSetupWizard, +} from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, From 0958aea1125bc76a87301fd1bf5132068a980d25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:23:06 -0700 Subject: [PATCH 018/331] refactor: move matrix msteams twitch to setup wizard --- extensions/matrix/src/channel.ts | 101 +-------- .../src/{onboarding.ts => setup-surface.ts} | 202 +++++++++++++----- extensions/msteams/src/channel.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 105 +++++---- extensions/twitch/src/onboarding.test.ts | 39 ++-- extensions/twitch/src/plugin.ts | 7 +- .../src/{onboarding.ts => setup-surface.ts} | 149 ++++++------- src/plugin-sdk/matrix.ts | 9 +- src/plugin-sdk/msteams.ts | 9 +- src/plugin-sdk/twitch.ts | 9 +- 10 files changed, 316 insertions(+), 332 deletions(-) rename extensions/matrix/src/{onboarding.ts => setup-surface.ts} (71%) rename extensions/msteams/src/{onboarding.ts => setup-surface.ts} (80%) rename extensions/twitch/src/{onboarding.ts => setup-surface.ts} (76%) diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index a6a33a7f627..8e3c858ecde 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,12 +6,10 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; @@ -30,9 +28,8 @@ import { type ResolvedMatrixAccount, } from "./matrix/accounts.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; -import { matrixOnboardingAdapter } from "./onboarding.js"; import { getMatrixRuntime } from "./runtime.js"; -import { normalizeSecretInputString } from "./secret-input.js"; +import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -66,38 +63,6 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined { return stripped || undefined; } -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - }, -): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; -} - const matrixConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), @@ -132,7 +97,7 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver = { id: "matrix", meta, - onboarding: matrixOnboardingAdapter, + setupWizard: matrixSetupWizard, pairing: { idLabel: "matrixUserId", normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), @@ -316,67 +281,7 @@ export const matrixPlugin: ChannelPlugin = { (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), }, actions: matrixMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId, - name, - }), - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId: DEFAULT_ACCOUNT_ID, - name: input.name, - }); - if (input.useEnv) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - matrix: { - ...namedConfig.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(namedConfig as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, - }); - }, - }, + setup: matrixSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/setup-surface.ts similarity index 71% rename from extensions/matrix/src/onboarding.ts rename to extensions/matrix/src/setup-surface.ts index 642522dbc50..9f37f000c46 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,19 +1,29 @@ -import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, - formatResolvedUnresolvedNote, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, - promptChannelAccessConfig, setTopLevelChannelGroupPolicy, - type SecretInput, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; @@ -22,6 +32,38 @@ import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; @@ -168,7 +210,7 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const matrixDmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, policyKey: "channels.matrix.dm.policy", @@ -178,26 +220,100 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; -export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); - return { - channel, - configured, - statusLines: [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ], - selectionHint: !sdkReady - ? "install @vector-im/matrix-bot-sdk" - : configured - ? "configured" - : "needs auth", - }; +export const matrixSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = normalizeSecretInputString(input.password); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; }, - configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (input.useEnv) { + return { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(next as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: normalizeSecretInputString(input.password), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, +}; + +export const matrixSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs homeserver + access token or password", + configuredHint: "configured", + unconfiguredHint: "needs auth", + resolveConfigured: ({ cfg }) => resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured, + resolveStatusLines: ({ cfg }) => { + const configured = resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured; + return [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ]; + }, + resolveSelectionHint: ({ cfg, configured }) => { + if (!isMatrixSdkAvailable()) { + return "install @vector-im/matrix-bot-sdk"; + } + return configured ? "configured" : "needs auth"; + }, + }, + credentials: [], + finalize: async ({ cfg, runtime, prompter, forceAllowFrom }) => { let next = cfg as CoreConfig; await ensureMatrixSdkInstalled({ runtime, @@ -231,16 +347,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (useEnv) { - next = { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - }; + next = matrixSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: true }, + }) as CoreConfig; if (forceAllowFrom) { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } @@ -284,7 +395,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { } if (!accessToken && !passwordConfigured()) { - // Ask auth method FIRST before asking for user ID const authMode = await prompter.select({ message: "Matrix auth method", options: [ @@ -300,11 +410,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - // With access token, we can fetch the userId automatically - don't prompt for it - // The client.ts will use whoami() to get it userId = ""; } else { - // Password auth requires user ID upfront userId = String( await prompter.text({ message: "Matrix user ID", @@ -333,7 +440,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { const passwordResult = await promptSingleChannelSecretInput({ cfg: next, prompter, - providerHint: "matrix", + providerHint: channel, credentialLabel: "password", accountConfigured: passwordPromptState.accountConfigured, canUseEnv: passwordPromptState.canUseEnv, @@ -359,7 +466,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { }), ).trim(); - // Ask about E2EE encryption const enableEncryption = await prompter.confirm({ message: "Enable end-to-end encryption (E2EE)?", initialValue: existing.encryption ?? false, @@ -375,7 +481,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { homeserver, userId: userId || undefined, accessToken: accessToken || undefined, - password: password, + password, deviceName: deviceName || undefined, encryption: enableEncryption || undefined, }, @@ -451,7 +557,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next }; }, - dmPolicy, + dmPolicy: matrixDmPolicy, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a5c8f0bbe58..a4e62e5e310 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -16,7 +16,6 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { msteamsOnboardingAdapter } from "./onboarding.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import { normalizeMSTeamsMessagingTarget, @@ -27,6 +26,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; +import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { @@ -56,7 +56,7 @@ export const msteamsPlugin: ChannelPlugin = { ...meta, aliases: [...meta.aliases], }, - onboarding: msteamsOnboardingAdapter, + setupWizard: msteamsSetupWizard, pairing: { idLabel: "msteamsUserId", normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), @@ -145,19 +145,7 @@ export const msteamsPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled: true, - }, - }, - }), - }, + setup: msteamsSetupAdapter, messaging: { normalizeTarget: normalizeMSTeamsMessagingTarget, targetResolver: { diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/setup-surface.ts similarity index 80% rename from extensions/msteams/src/onboarding.ts rename to extensions/msteams/src/setup-surface.ts index 11207e8ee49..8d5ebdbb5ef 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,21 +1,19 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, - WizardPrompter, - MSTeamsTeamConfig, -} from "openclaw/plugin-sdk/msteams"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, mergeAllowFromEntries, - promptChannelAccessConfig, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/msteams"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, @@ -29,7 +27,7 @@ const channel = "msteams" as const; function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "msteams", + channel, dmPolicy, }); } @@ -37,7 +35,7 @@ function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "msteams", + channel, allowFrom, }); } @@ -138,7 +136,7 @@ async function promptMSTeamsAllowFrom(params: { async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise { await prompter.note( [ - "1) Azure Bot registration → get App ID + Tenant ID", + "1) Azure Bot registration -> get App ID + Tenant ID", "2) Add a client secret (App Password)", "3) Set webhook URL + messaging endpoint", "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.", @@ -154,7 +152,7 @@ function setMSTeamsGroupPolicy( ): OpenClawConfig { return setTopLevelChannelGroupPolicy({ cfg, - channel: "msteams", + channel, groupPolicy, enabled: true, }); @@ -193,7 +191,7 @@ function setMSTeamsTeamsAllowlist( }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const msteamsDmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", @@ -203,21 +201,46 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMSTeamsAllowFrom, }; -export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { +export const msteamsSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), +}; + +export const msteamsSetupWizard: ChannelSetupWizard = { channel, - getStatus: async ({ cfg }) => { - const configured = - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || - hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); - return { - channel, - configured, - statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`], - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => { + return ( + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams) + ); + }, + resolveStatusLines: ({ cfg }) => { + const configured = + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); + return [`MS Teams: ${configured ? "configured" : "needs app credentials"}`]; + }, }, - configure: async ({ cfg, prompter }) => { + credentials: [], + finalize: async ({ cfg, prompter }) => { const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams); const hasConfigCreds = hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); const canUseEnv = Boolean( @@ -243,13 +266,11 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (keepEnv) { - next = { - ...next, - channels: { - ...next.channels, - msteams: { ...next.channels?.msteams, enabled: true }, - }, - }; + next = msteamsSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: {}, + }); } else { ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } @@ -308,17 +329,17 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { try { - const resolved = await resolveMSTeamsChannelAllowlist({ + const resolvedEntries = await resolveMSTeamsChannelAllowlist({ cfg: next, entries: accessConfig.entries, }); - const resolvedChannels = resolved.filter( + const resolvedChannels = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && entry.channelId, ); - const resolvedTeams = resolved.filter( + const resolvedTeams = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && !entry.channelId, ); - const unresolved = resolved + const unresolved = resolvedEntries .filter((entry) => !entry.resolved) .map((entry) => entry.input); @@ -370,7 +391,7 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, - dmPolicy, + dmPolicy: msteamsDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/twitch/src/onboarding.test.ts b/extensions/twitch/src/onboarding.test.ts index b8946eefc49..47b4e179e5e 100644 --- a/extensions/twitch/src/onboarding.test.ts +++ b/extensions/twitch/src/onboarding.test.ts @@ -1,5 +1,5 @@ /** - * Tests for onboarding.ts helpers + * Tests for setup-surface.ts helpers * * Tests cover: * - promptToken helper @@ -15,11 +15,6 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/twitch"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TwitchAccountConfig } from "./types.js"; -vi.mock("openclaw/plugin-sdk/twitch", () => ({ - formatDocsLink: (url: string, fallback: string) => fallback || url, - promptChannelAccessConfig: vi.fn(async () => null), -})); - // Mock the helpers we're testing const mockPromptText = vi.fn(); const mockPromptConfirm = vi.fn(); @@ -35,7 +30,7 @@ const mockAccount: TwitchAccountConfig = { channel: "#testchannel", }; -describe("onboarding helpers", () => { +describe("setup surface helpers", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -46,7 +41,7 @@ describe("onboarding helpers", () => { describe("promptToken", () => { it("should return existing token when user confirms to keep it", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(true); @@ -61,7 +56,7 @@ describe("onboarding helpers", () => { }); it("should prompt for new token when user doesn't keep existing", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:newtoken123"); @@ -77,7 +72,7 @@ describe("onboarding helpers", () => { }); it("should use env token as initial value when provided", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:fromenv"); @@ -92,7 +87,7 @@ describe("onboarding helpers", () => { }); it("should validate token format", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); // Set up mocks - user doesn't want to keep existing token mockPromptConfirm.mockResolvedValueOnce(false); @@ -124,7 +119,7 @@ describe("onboarding helpers", () => { }); it("should return early when no existing token and no env token", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("oauth:newtoken"); @@ -137,7 +132,7 @@ describe("onboarding helpers", () => { describe("promptUsername", () => { it("should prompt for username with validation", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("mybot"); @@ -152,7 +147,7 @@ describe("onboarding helpers", () => { }); it("should use existing username as initial value", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("testbot"); @@ -168,7 +163,7 @@ describe("onboarding helpers", () => { describe("promptClientId", () => { it("should prompt for client ID with validation", async () => { - const { promptClientId } = await import("./onboarding.js"); + const { promptClientId } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("abc123xyz"); @@ -185,7 +180,7 @@ describe("onboarding helpers", () => { describe("promptChannelName", () => { it("should return channel name when provided", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("#mychannel"); @@ -195,7 +190,7 @@ describe("onboarding helpers", () => { }); it("should require a non-empty channel name", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue(""); @@ -210,7 +205,7 @@ describe("onboarding helpers", () => { describe("promptRefreshTokenSetup", () => { it("should return empty object when user declines", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); @@ -224,7 +219,7 @@ describe("onboarding helpers", () => { }); it("should prompt for credentials when user accepts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm .mockResolvedValueOnce(true) // First call: useRefresh @@ -242,7 +237,7 @@ describe("onboarding helpers", () => { }); it("should use existing values as initial prompts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); const accountWithRefresh = { ...mockAccount, @@ -267,7 +262,7 @@ describe("onboarding helpers", () => { describe("configureWithEnvToken", () => { it("should return null when user declines env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mock - user declines env token mockPromptConfirm.mockReset().mockResolvedValue(false as never); @@ -287,7 +282,7 @@ describe("onboarding helpers", () => { }); it("should prompt for username and clientId when using env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mocks - user accepts env token mockPromptConfirm.mockReset().mockResolvedValue(true as never); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 11cf90b8893..3958a05fd8b 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -12,10 +12,10 @@ import { twitchMessageActions } from "./actions.js"; import { removeClientManager } from "./client-manager-registry.js"; import { TwitchConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js"; -import { twitchOnboardingAdapter } from "./onboarding.js"; import { twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; import { resolveTwitchTargets } from "./resolver.js"; +import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js"; import { collectTwitchStatusIssues } from "./status.js"; import { resolveTwitchToken } from "./token.js"; import type { @@ -51,8 +51,9 @@ export const twitchPlugin: ChannelPlugin = { aliases: ["twitch-chat"], } satisfies ChannelMeta, - /** Onboarding adapter */ - onboarding: twitchOnboardingAdapter, + /** Setup wizard surface */ + setup: twitchSetupAdapter, + setupWizard: twitchSetupWizard, /** Pairing configuration */ pairing: { diff --git a/extensions/twitch/src/onboarding.ts b/extensions/twitch/src/setup-surface.ts similarity index 76% rename from extensions/twitch/src/onboarding.ts rename to extensions/twitch/src/setup-surface.ts index 060857bf383..776644a2d23 100644 --- a/extensions/twitch/src/onboarding.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -1,25 +1,21 @@ /** - * Twitch onboarding adapter for CLI setup wizard. + * Twitch setup wizard surface for CLI setup. */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; -import { - formatDocsLink, - promptChannelAccessConfig, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/twitch"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; const channel = "twitch" as const; -/** - * Set Twitch account configuration - */ -function setTwitchAccount( +export function setTwitchAccount( cfg: OpenClawConfig, account: Partial, ): OpenClawConfig { @@ -59,9 +55,6 @@ function setTwitchAccount( }; } -/** - * Note about Twitch setup - */ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { await prompter.note( [ @@ -77,17 +70,13 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { ); } -/** - * Prompt for Twitch OAuth token with early returns. - */ -async function promptToken( +export async function promptToken( prompter: WizardPrompter, account: TwitchAccountConfig | null, envToken: string | undefined, ): Promise { const existingToken = account?.accessToken ?? ""; - // If we have an existing token and no env var, ask if we should keep it if (existingToken && !envToken) { const keepToken = await prompter.confirm({ message: "Access token already configured. Keep it?", @@ -98,7 +87,6 @@ async function promptToken( } } - // Prompt for new token return String( await prompter.text({ message: "Twitch OAuth token (oauth:...)", @@ -117,10 +105,7 @@ async function promptToken( ).trim(); } -/** - * Prompt for Twitch username. - */ -async function promptUsername( +export async function promptUsername( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -133,10 +118,7 @@ async function promptUsername( ).trim(); } -/** - * Prompt for Twitch Client ID. - */ -async function promptClientId( +export async function promptClientId( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -149,27 +131,20 @@ async function promptClientId( ).trim(); } -/** - * Prompt for optional channel name. - */ -async function promptChannelName( +export async function promptChannelName( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { - const channelName = String( + return String( await prompter.text({ message: "Channel to join", initialValue: account?.channel ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return channelName; } -/** - * Prompt for token refresh credentials (client secret and refresh token). - */ -async function promptRefreshTokenSetup( +export async function promptRefreshTokenSetup( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise<{ clientSecret?: string; refreshToken?: string }> { @@ -203,10 +178,7 @@ async function promptRefreshTokenSetup( return { clientSecret, refreshToken }; } -/** - * Configure with env token path (returns early if user chooses env token). - */ -async function configureWithEnvToken( +export async function configureWithEnvToken( cfg: OpenClawConfig, prompter: WizardPrompter, account: TwitchAccountConfig | null, @@ -228,7 +200,7 @@ async function configureWithEnvToken( const cfgWithAccount = setTwitchAccount(cfg, { username, clientId, - accessToken: "", // Will use env var + accessToken: "", enabled: true, }); @@ -239,9 +211,6 @@ async function configureWithEnvToken( return { cfg: cfgWithAccount }; } -/** - * Set Twitch access control (role-based) - */ function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], @@ -259,14 +228,13 @@ function setTwitchAccessControl( }); } -const dmPolicy: ChannelOnboardingDmPolicy = { +const twitchDmPolicy: ChannelOnboardingDmPolicy = { label: "Twitch", channel, - policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy + policyKey: "channels.twitch.allowedRoles", allowFromKey: "channels.twitch.accounts.default.allowFrom", getCurrent: (cfg) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - // Map allowedRoles to policy equivalent + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -278,10 +246,10 @@ const dmPolicy: ChannelOnboardingDmPolicy = { setPolicy: (cfg, policy) => { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"]; - return setTwitchAccessControl(cfg, allowedRoles, true); + return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true); }, promptAllowFrom: async ({ cfg, prompter }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ @@ -295,28 +263,43 @@ const dmPolicy: ChannelOnboardingDmPolicy = { .map((s) => s.trim()) .filter(Boolean); - return setTwitchAccount(cfg, { + return setTwitchAccount(cfg as OpenClawConfig, { ...(account ?? undefined), allowFrom, }); }, }; -export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - const configured = account ? isAccountConfigured(account) : false; +export const twitchSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => + setTwitchAccount(cfg, { + enabled: true, + }), +}; - return { - channel, - configured, - statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`], - selectionHint: configured ? "configured" : "needs setup", - }; +export const twitchSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs username, token, and clientId", + configuredHint: "configured", + unconfiguredHint: "needs setup", + resolveConfigured: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account ? isAccountConfigured(account) : false; + }, + resolveStatusLines: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + const configured = account ? isAccountConfigured(account) : false; + return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`]; + }, }, - configure: async ({ cfg, prompter, forceAllowFrom }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + credentials: [], + finalize: async ({ cfg, prompter, forceAllowFrom }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (!account || !isAccountConfigured(account)) { await noteTwitchSetupHelp(prompter); @@ -324,29 +307,27 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim(); - // Check if env var is set and config is empty if (envToken && !account?.accessToken) { const envResult = await configureWithEnvToken( - cfg, + cfg as OpenClawConfig, prompter, account, envToken, forceAllowFrom, - dmPolicy, + twitchDmPolicy, ); if (envResult) { return envResult; } } - // Prompt for credentials const username = await promptUsername(prompter, account); const token = await promptToken(prompter, account, envToken); const clientId = await promptClientId(prompter, account); const channelName = await promptChannelName(prompter, account); const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account); - const cfgWithAccount = setTwitchAccount(cfg, { + const cfgWithAccount = setTwitchAccount(cfg as OpenClawConfig, { username, accessToken: token, clientId, @@ -357,11 +338,10 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }); const cfgWithAllowFrom = - forceAllowFrom && dmPolicy.promptAllowFrom - ? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) + forceAllowFrom && twitchDmPolicy.promptAllowFrom + ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - // Prompt for access control if allowFrom not set if (!account?.allowFrom || account.allowFrom.length === 0) { const accessConfig = await promptChannelAccessConfig({ prompter, @@ -384,14 +364,15 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { ? ["moderator", "vip"] : []; - const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true); - return { cfg: cfgWithAccessControl }; + return { + cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), + }; } } return { cfg: cfgWithAllowFrom }; }, - dmPolicy, + dmPolicy: twitchDmPolicy, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record @@ -405,13 +386,3 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }; }, }; - -// Export helper functions for testing -export { - promptToken, - promptUsername, - promptClientId, - promptChannelName, - promptRefreshTokenSetup, - configureWithEnvToken, -}; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index ba4cad93a92..52d18e4665f 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -32,11 +32,6 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -113,3 +108,7 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; +export { + matrixSetupAdapter, + matrixSetupWizard, +} from "../../extensions/matrix/src/setup-surface.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index b73aec7c779..d99f703ed64 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -32,11 +32,6 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, @@ -122,3 +117,7 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + msteamsSetupAdapter, + msteamsSetupWizard, +} from "../../extensions/msteams/src/setup-surface.js"; diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 7ea8a9f5f4b..907cdd171fa 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -22,11 +22,6 @@ export type { ChannelStatusIssue, } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export type { OpenClawConfig } from "../config/config.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; @@ -38,3 +33,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + twitchSetupAdapter, + twitchSetupWizard, +} from "../../extensions/twitch/src/setup-surface.js"; From 26a8aee01cfbdf15e23c13eb2d62841aa119e6bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:23:18 -0700 Subject: [PATCH 019/331] refactor: drop channel onboarding fallback --- docs/tools/plugin.md | 15 ------------- src/channels/plugins/types.plugin.ts | 3 --- src/commands/onboard-channels.e2e.test.ts | 20 +++++++++-------- src/commands/onboard-channels.ts | 10 ++++++++- src/commands/onboarding/registry.ts | 3 --- src/plugin-sdk/subpaths.test.ts | 26 +++++++++++++++++++++++ 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 1cfe6ae1cd0..4113c9fbd05 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1437,16 +1437,6 @@ Preferred setup split: - `plugin.setup` owns account-id normalization, validation, and config writes. - `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status, credential, DM allowlist, and channel-access descriptors. -Use `plugin.onboarding` only when the host-owned setup wizard cannot express the flow and the -channel needs to fully own prompting. - -Wizard precedence: - -1. `plugin.setupWizard` (preferred, host-owned prompts) -2. `plugin.onboarding.configureInteractive` -3. `plugin.onboarding.configureWhenConfigured` (already-configured channel only) -4. `plugin.onboarding.configure` - `plugin.setupWizard` is best for channels that fit the shared pattern: - one account picker driven by `plugin.config.listAccountIds` @@ -1458,11 +1448,6 @@ Wizard precedence: - optional DM allowlist resolution (for example `@username` -> numeric id) - optional completion note after setup finishes -`plugin.onboarding` hooks still return the same values as before: - -- `"skip"` leaves selection and account tracking unchanged. -- `{ cfg, accountId? }` applies config updates and records account selection. - ### Write a new messaging channel (step‑by‑step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 3c821ab601b..cf09af29048 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingAdapter } from "./onboarding-types.js"; import type { ChannelSetupWizard } from "./setup-wizard.js"; import type { ChannelAuthAdapter, @@ -57,8 +56,6 @@ export type ChannelPlugin; configSchema?: ChannelConfigSchema; diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 6c505c6d4e2..c469f50a54e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -472,15 +472,17 @@ describe("setupChannels", () => { )?.accounts?.[accountId] ?? { accountId }, setAccountEnabled, }, - onboarding: { - getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ - channel: "msteams", - configured: Boolean( - (cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId, - ), - statusLines: [], - selectionHint: "configured", - })), + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) => + Boolean((cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId), + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "configured", + }, + credentials: [], }, outbound: { deliveryMode: "direct" }, }, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 4a313ebf913..81deb95e901 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,6 +5,7 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -354,7 +355,14 @@ export async function setupChannels( if (adapter) { return adapter; } - return scopedPluginsById.get(channel)?.onboarding; + const scopedPlugin = scopedPluginsById.get(channel); + if (!scopedPlugin?.setupWizard) { + return undefined; + } + return buildChannelOnboardingAdapterFromSetupWizard({ + plugin: scopedPlugin, + wizard: scopedPlugin.setupWizard, + }); }; const preloadConfiguredExternalPlugins = () => { // Keep onboarding memory bounded by snapshot-loading only configured external plugins. diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 14074daf193..99009ee8fac 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -60,9 +60,6 @@ function resolveChannelOnboardingAdapter( setupWizardAdapters.set(plugin, adapter); return adapter; } - if (plugin.onboarding) { - return plugin.onboarding; - } return undefined; } diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 09341c4e82b..42d69512925 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -101,6 +101,12 @@ describe("plugin-sdk subpath exports", () => { expect("resolveWhatsAppMentionStripPatterns" in whatsappSdk).toBe(false); }); + it("exports Feishu helpers", async () => { + const feishuSdk = await import("openclaw/plugin-sdk/feishu"); + expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); + expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); + }); + it("exports LINE helpers", () => { expect(typeof lineSdk.processLineMessage).toBe("function"); expect(typeof lineSdk.createInfoCard).toBe("function"); @@ -109,6 +115,8 @@ describe("plugin-sdk subpath exports", () => { it("exports Microsoft Teams helpers", () => { expect(typeof msteamsSdk.resolveControlCommandGate).toBe("function"); expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function"); + expect(typeof msteamsSdk.msteamsSetupWizard).toBe("object"); + expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object"); }); it("exports Google Chat helpers", async () => { @@ -117,6 +125,18 @@ describe("plugin-sdk subpath exports", () => { expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); }); + it("exports Zalo helpers", async () => { + const zaloSdk = await import("openclaw/plugin-sdk/zalo"); + expect(typeof zaloSdk.zaloSetupWizard).toBe("object"); + expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); + }); + + it("exports Zalouser helpers", async () => { + const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); + expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); + expect(typeof zalouserSdk.zalouserSetupAdapter).toBe("object"); + }); + it("exports Tlon helpers", async () => { const tlonSdk = await import("openclaw/plugin-sdk/tlon"); expect(typeof tlonSdk.fetchWithSsrFGuard).toBe("function"); @@ -142,6 +162,10 @@ describe("plugin-sdk subpath exports", () => { const bluebubbles = await import("openclaw/plugin-sdk/bluebubbles"); expect(typeof bluebubbles.parseFiniteNumber).toBe("function"); + const matrix = await import("openclaw/plugin-sdk/matrix"); + expect(typeof matrix.matrixSetupWizard).toBe("object"); + expect(typeof matrix.matrixSetupAdapter).toBe("object"); + const mattermost = await import("openclaw/plugin-sdk/mattermost"); expect(typeof mattermost.parseStrictPositiveInteger).toBe("function"); @@ -151,6 +175,8 @@ describe("plugin-sdk subpath exports", () => { const twitch = await import("openclaw/plugin-sdk/twitch"); expect(typeof twitch.DEFAULT_ACCOUNT_ID).toBe("string"); expect(typeof twitch.normalizeAccountId).toBe("function"); + expect(typeof twitch.twitchSetupWizard).toBe("object"); + expect(typeof twitch.twitchSetupAdapter).toBe("object"); const zalo = await import("openclaw/plugin-sdk/zalo"); expect(typeof zalo.resolveClientIp).toBe("function"); From 1e196db49d8e0ad3cc246fc411d396df74ba393b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:27:03 +0000 Subject: [PATCH 020/331] fix: quiet discord startup logs --- extensions/discord/src/monitor/provider.test.ts | 7 ++++++- extensions/discord/src/monitor/provider.ts | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 8ded5f982ae..f00baf73ff8 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -46,10 +46,12 @@ const { resolveDiscordAllowlistConfigMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, + isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock, } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const isVerboseMock = vi.fn(() => false); const shouldLogVerboseMock = vi.fn(() => false); return { clientHandleDeployRequestMock: vi.fn(async () => undefined), @@ -112,6 +114,7 @@ const { })), resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeSkillsEnabledMock: vi.fn(() => false), + isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock: vi.fn(), }; @@ -213,6 +216,7 @@ vi.mock("../../../../src/config/config.js", () => ({ vi.mock("../../../../src/globals.js", () => ({ danger: (v: string) => v, + isVerbose: isVerboseMock, logVerbose: vi.fn(), shouldLogVerbose: shouldLogVerboseMock, warn: (v: string) => v, @@ -438,6 +442,7 @@ describe("monitorDiscordProvider", () => { }); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + isVerboseMock.mockClear().mockReturnValue(false); shouldLogVerboseMock.mockClear().mockReturnValue(false); voiceRuntimeModuleLoadedMock.mockClear(); }); @@ -846,7 +851,7 @@ describe("monitorDiscordProvider", () => { emitter.emit("debug", "WebSocket connection opened"); return { id: "bot-1", username: "Molty" }; }); - shouldLogVerboseMock.mockReturnValue(true); + isVerboseMock.mockReturnValue(true); await monitorDiscordProvider({ config: baseConfig(), diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 4f8af71f0d5..d4ef01ab0d8 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -38,7 +38,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "../../../../src/config/runtime-group-policy.js"; import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; +import { danger, isVerbose, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; import { formatErrorMessage } from "../../../../src/infra/errors.js"; import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; import { getPluginCommandSpecs } from "../../../../src/plugins/commands.js"; @@ -363,7 +363,7 @@ function logDiscordStartupPhase(params: { gateway?: GatewayPlugin; details?: string; }) { - if (!shouldLogVerbose()) { + if (!isVerbose()) { return; } const elapsedMs = Math.max(0, Date.now() - params.startAt); @@ -775,7 +775,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const lifecycleGateway = client.getPlugin("gateway"); earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway); onEarlyGatewayDebug = (msg: unknown) => { - if (!shouldLogVerbose()) { + if (!isVerbose()) { return; } runtime.log?.( From 961f42e0cf9def1b1771394928c5e95d4cb68f8b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:28:39 -0700 Subject: [PATCH 021/331] Slack: lazy-load setup wizard surface --- extensions/slack/src/channel.runtime.ts | 1 + extensions/slack/src/channel.ts | 10 +- extensions/slack/src/setup-core.ts | 495 ++++++++++++++++++++++++ extensions/slack/src/setup-surface.ts | 78 +--- src/plugin-sdk/index.ts | 3 +- src/plugin-sdk/slack.ts | 3 +- 6 files changed, 510 insertions(+), 80 deletions(-) create mode 100644 extensions/slack/src/channel.runtime.ts create mode 100644 extensions/slack/src/setup-core.ts diff --git a/extensions/slack/src/channel.runtime.ts b/extensions/slack/src/channel.runtime.ts new file mode 100644 index 00000000000..eefcc2c6215 --- /dev/null +++ b/extensions/slack/src/channel.runtime.ts @@ -0,0 +1 @@ +export { slackSetupWizard } from "./setup-surface.js"; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5903e5755b2..1a2232bb5e7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -37,10 +37,14 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; -import { slackSetupAdapter, slackSetupWizard } from "./setup-surface.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; const meta = getChatChannelMeta("slack"); +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -106,6 +110,10 @@ const slackConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["botToken", "appToken", "name"], }); +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts new file mode 100644 index 00000000000..0cf7903e6d4 --- /dev/null +++ b/extensions/slack/src/setup-core.ts @@ -0,0 +1,495 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; + +const channel = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { enabled: true }, + }); +} + +function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { channels }, + }); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Slack env tokens can only be used for the default account."; + } + if (!input.useEnv && (!input.botToken || !input.appToken)) { + return "Slack requires --bot-token and --app-token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + accounts: { + ...next.channels?.slack?.accounts, + [accountId]: { + ...next.channels?.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + const slackDmPolicy: ChannelOnboardingDmPolicy = { + label: "Slack", + channel, + policyKey: "channels.slack.dmPolicy", + allowFromKey: "channels.slack.allowFrom", + getCurrent: (cfg: OpenClawConfig) => + cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs tokens", + configuredHint: "configured", + unconfiguredHint: "needs tokens", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listSlackAccountIds(cfg).some((accountId) => { + const account = inspectSlackAccount({ cfg, accountId }); + return account.configured; + }), + }, + introNote: { + title: "Slack socket mode tokens", + lines: buildSlackSetupLines(), + shouldShow: ({ cfg, accountId }) => + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + }, + envShortcut: { + prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + preferredEnvVar: "SLACK_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()) && + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: "slack-bot", + credentialLabel: "Slack bot token", + preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", + inputPrompt: "Enter Slack bot token (xoxb-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), + resolvedValue: resolved.botToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + botToken: value, + }, + }), + }, + { + inputKey: "appToken", + providerHint: "slack-app", + credentialLabel: "Slack app token", + preferredEnvVar: "SLACK_APP_TOKEN", + envPrompt: "SLACK_APP_TOKEN detected. Use env var?", + keepPrompt: "Slack app token already configured. Keep it?", + inputPrompt: "Enter Slack app token (xapp-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), + resolvedValue: resolved.appToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + appToken: value, + }, + }), + }, + ], + dmPolicy: slackDmPolicy, + allowFrom: { + helpTitle: "Slack allowlist", + helpLines: [ + "Allowlist Slack DMs by username (we resolve to user ids).", + "Examples:", + "- U12345678", + "- @alice", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + ], + credentialInputKey: "botToken", + message: "Slack allowFrom (usernames or ids)", + placeholder: "@alice, U12345678", + invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", + parseId: (value: string) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixPattern: /^(slack:|user:)/i, + idPattern: /^[A-Z][A-Z0-9]+$/i, + normalizeId: (id) => id.toUpperCase(), + }), + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + apply: ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + groupAccess: { + label: "Slack channels", + placeholder: "#general, #private, C123", + currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) + .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .map(([key]) => key), + updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), + setPolicy: ({ + cfg, + accountId, + policy, + }: { + cfg: OpenClawConfig; + accountId: string; + policy: "open" | "allowlist" | "disabled"; + }) => + setAccountGroupPolicyForChannel({ + cfg, + channel, + accountId, + groupPolicy: policy, + }), + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + try { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess) { + return entries; + } + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Slack channels", + error, + }); + await noteChannelLookupSummary({ + prompter, + label: "Slack channels", + resolvedSections: [], + unresolved: entries, + }); + return entries; + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + }, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index ad743ffa080..dafcad32f74 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -10,15 +10,10 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -33,6 +28,7 @@ import { } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; +import { slackSetupAdapter } from "./setup-core.js"; const channel = "slack" as const; @@ -238,78 +234,6 @@ function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { return hasConfiguredBotToken && hasConfiguredAppToken; } -export const slackSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Slack env tokens can only be used for the default account."; - } - if (!input.useEnv && (!input.botToken || !input.appToken)) { - return "Slack requires --bot-token and --app-token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, -}; - export const slackSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 089876dc7bc..90292907149 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -745,7 +745,8 @@ export { extractSlackToolSend, listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 779560b930b..7e200ab5995 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -39,7 +39,8 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; From 1c4f52d6a1164e437ba39458b7fd93c0c44248d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:36:41 -0700 Subject: [PATCH 022/331] Feishu: drop stale runtime onboarding export --- extensions/feishu/src/channel.runtime.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index 8068fb350d3..61f637a94de 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -1,5 +1,4 @@ export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js"; -export { feishuOnboardingAdapter } from "./onboarding.js"; export { feishuOutbound } from "./outbound.js"; export { probeFeishu } from "./probe.js"; export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; From d663df7a7445fe744bb959c0338ffef4411470dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:36:57 -0700 Subject: [PATCH 023/331] Discord: lazy-load setup wizard surface --- extensions/discord/src/channel.runtime.ts | 1 + extensions/discord/src/channel.ts | 10 +- extensions/discord/src/setup-core.ts | 348 ++++++++++++++++++++++ extensions/discord/src/setup-surface.ts | 129 +------- src/plugin-sdk/discord.ts | 6 +- src/plugin-sdk/index.ts | 6 +- 6 files changed, 369 insertions(+), 131 deletions(-) create mode 100644 extensions/discord/src/channel.runtime.ts create mode 100644 extensions/discord/src/setup-core.ts diff --git a/extensions/discord/src/channel.runtime.ts b/extensions/discord/src/channel.runtime.ts new file mode 100644 index 00000000000..bc22b64706a --- /dev/null +++ b/extensions/discord/src/channel.runtime.ts @@ -0,0 +1 @@ +export { discordSetupWizard } from "./setup-surface.js"; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0123553fcb7..0af60e096bc 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -35,7 +35,7 @@ import { } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; -import { discordSetupAdapter, discordSetupWizard } from "./setup-surface.js"; +import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime @@ -43,6 +43,10 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], @@ -73,6 +77,10 @@ const discordConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["token", "name"], }); +const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts new file mode 100644 index 00000000000..cec63dd01ec --- /dev/null +++ b/extensions/discord/src/setup-core.ts @@ -0,0 +1,348 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; + +const channel = "discord" as const; + +export const DISCORD_TOKEN_HELP_LINES = [ + "1) Discord Developer Portal -> Applications -> New Application", + "2) Bot -> Add Bot -> Reset Token -> copy token", + "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", + "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", + `Docs: ${formatDocsLink("/discord", "discord")}`, +]; + +export function setDiscordGuildChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): OpenClawConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record = { ...baseGuilds }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { guilds }, + }); +} + +export function parseDiscordAllowFromId(value: string): string | null { + return parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@!?(\d+)>$/, + prefixPattern: /^(user:|discord:)/i, + idPattern: /^\d+$/, + }); +} + +export const discordSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + const discordDmPolicy: ChannelOnboardingDmPolicy = { + label: "Discord", + channel, + policyKey: "channels.discord.dmPolicy", + allowFromKey: "channels.discord.allowFrom", + getCurrent: (cfg: OpenClawConfig) => + cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "configured", + unconfiguredHint: "needs token", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listDiscordAccountIds(cfg).some((accountId) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return account.configured; + }), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Discord bot token", + preferredEnvVar: "DISCORD_BOT_TOKEN", + helpTitle: "Discord bot token", + helpLines: DISCORD_TOKEN_HELP_LINES, + envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", + keepPrompt: "Discord token already configured. Keep it?", + inputPrompt: "Enter Discord bot token", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: account.tokenStatus !== "missing", + resolvedValue: account.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.DISCORD_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + ], + groupAccess: { + label: "Discord channels", + placeholder: "My Server/#general, guildId/channelId, #support", + currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( + ([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; + return [input]; + } + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }, + ), + updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), + setPolicy: ({ + cfg, + accountId, + policy, + }: { + cfg: OpenClawConfig; + accountId: string; + policy: "open" | "allowlist" | "disabled"; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { groupPolicy: policy }, + }), + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess) { + return entries.map((input) => ({ input, resolved: false })); + } + try { + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error, + }); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [], + unresolved: entries, + }); + return entries.map((input) => ({ input, resolved: false })); + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), + }, + allowFrom: { + credentialInputKey: "token", + helpTitle: "Discord allowlist", + helpLines: [ + "Allowlist Discord DMs by username (we resolve to user ids).", + "Examples:", + "- 123456789012345678", + "- @alice", + "- alice#1234", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ], + message: "Discord allowFrom (usernames or ids)", + placeholder: "@alice, 123456789012345678", + invalidWithoutCredentialNote: + "Bot token missing; use numeric user ids (or mention form) only.", + parseId: parseDiscordAllowFromId, + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + apply: async ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: discordDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index e03c7ef1e16..610b79a5efa 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -9,15 +9,9 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { inspectDiscordAccount } from "./account-inspect.js"; @@ -32,58 +26,15 @@ import { type DiscordChannelResolution, } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; +import { + discordSetupAdapter, + DISCORD_TOKEN_HELP_LINES, + parseDiscordAllowFromId, + setDiscordGuildChannelAllowlist, +} from "./setup-core.js"; const channel = "discord" as const; -const DISCORD_TOKEN_HELP_LINES = [ - "1) Discord Developer Portal -> Applications -> New Application", - "2) Bot -> Add Bot -> Reset Token -> copy token", - "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", - "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", - `Docs: ${formatDocsLink("/discord", "discord")}`, -]; - -function setDiscordGuildChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - entries: Array<{ - guildKey: string; - channelKey?: string; - }>, -): OpenClawConfig { - const baseGuilds = - accountId === DEFAULT_ACCOUNT_ID - ? (cfg.channels?.discord?.guilds ?? {}) - : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); - const guilds: Record = { ...baseGuilds }; - for (const entry of entries) { - const guildKey = entry.guildKey || "*"; - const existing = guilds[guildKey] ?? {}; - if (entry.channelKey) { - const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; - guilds[guildKey] = { ...existing, channels }; - } else { - guilds[guildKey] = existing; - } - } - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { guilds }, - }); -} - -function parseDiscordAllowFromId(value: string): string | null { - return parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@!?(\d+)>$/, - prefixPattern: /^(user:|discord:)/i, - idPattern: /^\d+$/, - }); -} - async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { if (!params.token?.trim()) { return params.entries.map((input) => ({ @@ -157,72 +108,6 @@ const discordDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptDiscordAllowFrom, }; -export const discordSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "DISCORD_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token) { - return "Discord requires token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, -}; - export const discordSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f4ffe6ef809..27f6c17bdff 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -35,10 +35,8 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 90292907149..a6044a0da84 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -690,10 +690,8 @@ export { export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, From de6666b8958f92f03a1b2f4b430248f27f13ddc9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:44:59 -0700 Subject: [PATCH 024/331] Signal: lazy-load setup wizard surface --- extensions/signal/src/channel.runtime.ts | 1 + extensions/signal/src/channel.ts | 10 +- extensions/signal/src/setup-core.ts | 275 +++++++++++++++++++++++ extensions/signal/src/setup-surface.ts | 141 +----------- src/plugin-sdk/index.ts | 6 +- src/plugin-sdk/signal.ts | 6 +- 6 files changed, 295 insertions(+), 144 deletions(-) create mode 100644 extensions/signal/src/channel.runtime.ts create mode 100644 extensions/signal/src/setup-core.ts diff --git a/extensions/signal/src/channel.runtime.ts b/extensions/signal/src/channel.runtime.ts new file mode 100644 index 00000000000..0403246478f --- /dev/null +++ b/extensions/signal/src/channel.runtime.ts @@ -0,0 +1 @@ +export { signalSetupWizard } from "./setup-surface.js"; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index ccf635e60cf..8b2f0998ff9 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -28,7 +28,15 @@ import { } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getSignalRuntime } from "./runtime.js"; -import { signalSetupAdapter, signalSetupWizard } from "./setup-surface.js"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts new file mode 100644 index 00000000000..2f46c4d4c4c --- /dev/null +++ b/extensions/signal/src/setup-core.ts @@ -0,0 +1,275 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164 } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "./accounts.js"; + +const channel = "signal" as const; +const MIN_E164_DIGITS = 5; +const MAX_E164_DIGITS = 15; +const DIGITS_ONLY = /^\d+$/; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; + +export function normalizeSignalAccountInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeE164(trimmed); + const digits = normalized.slice(1); + if (!DIGITS_ONLY.test(digits)) { + return null; + } + if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { + return null; + } + return `+${digits}`; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + if (entry.toLowerCase().startsWith("uuid:")) { + const id = entry.slice("uuid:".length).trim(); + if (!id) { + return { error: "Invalid uuid entry" }; + } + return { value: `uuid:${id}` }; + } + if (isUuidLike(entry)) { + return { value: `uuid:${entry}` }; + } + const normalized = normalizeSignalAccountInput(entry); + if (!normalized) { + return { error: `Invalid entry: ${entry}` }; + } + return { value: normalized }; + }); +} + +function buildSignalSetupPatch(input: { + signalNumber?: string; + cliPath?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; +}) { + return { + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +async function promptSignalAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "Signal allowlist", + noteLines: [ + "Allowlist Signal DMs by sender id.", + "Examples:", + "- +15555550123", + "- uuid:123e4567-e89b-12d3-a456-426614174000", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + message: "Signal allowFrom (E.164 or uuid)", + placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", + parseEntries: parseSignalAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +export const signalSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + accounts: { + ...next.channels?.signal?.accounts, + [accountId]: { + ...next.channels?.signal?.accounts?.[accountId], + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { + const signalDmPolicy: ChannelOnboardingDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ), + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + }, + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + initialValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + shouldPrompt: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }, + { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + }, + ], + completionNote: { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + }, + dmPolicy: signalDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 6a7b7604450..51dbbd5625a 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -5,89 +5,29 @@ import { setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import { installSignalCli } from "../../../src/commands/signal-install.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164 } from "../../../src/utils.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, } from "./accounts.js"; +import { + normalizeSignalAccountInput, + parseSignalAllowFromEntries, + signalSetupAdapter, +} from "./setup-core.js"; const channel = "signal" as const; -const MIN_E164_DIGITS = 5; -const MAX_E164_DIGITS = 15; -const DIGITS_ONLY = /^\d+$/; const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; -export function normalizeSignalAccountInput(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeE164(trimmed); - const digits = normalized.slice(1); - if (!DIGITS_ONLY.test(digits)) { - return null; - } - if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { - return null; - } - return `+${digits}`; -} - -function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); -} - -export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - if (entry.toLowerCase().startsWith("uuid:")) { - const id = entry.slice("uuid:".length).trim(); - if (!id) { - return { error: "Invalid uuid entry" }; - } - return { value: `uuid:${id}` }; - } - if (isUuidLike(entry)) { - return { value: `uuid:${entry}` }; - } - const normalized = normalizeSignalAccountInput(entry); - if (!normalized) { - return { error: `Invalid entry: ${entry}` }; - } - return { value: normalized }; - }); -} - -function buildSignalSetupPatch(input: { - signalNumber?: string; - cliPath?: string; - httpUrl?: string; - httpHost?: string; - httpPort?: string; -}) { - return { - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }; -} - async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -131,75 +71,6 @@ const signalDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptSignalAllowFrom, }; -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; - export const signalSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a6044a0da84..04d03c56f8e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -782,10 +782,8 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../../extensions/signal/src/accounts.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 2eb0497c277..f57a046ab03 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -16,10 +16,8 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; From fb991e6f3156aeb6bbf3f15e27926cb9e2697572 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:46:22 -0700 Subject: [PATCH 025/331] perf(plugins): lazy-load setup surfaces --- docs/tools/plugin.md | 10 +- extensions/bluebubbles/package.json | 1 + extensions/bluebubbles/setup-entry.ts | 5 + extensions/feishu/package.json | 1 + extensions/feishu/setup-entry.ts | 5 + extensions/googlechat/package.json | 1 + extensions/googlechat/setup-entry.ts | 6 + extensions/irc/package.json | 3 +- extensions/irc/setup-entry.ts | 5 + extensions/matrix/package.json | 1 + extensions/matrix/setup-entry.ts | 5 + extensions/msteams/package.json | 1 + extensions/msteams/setup-entry.ts | 5 + extensions/nextcloud-talk/package.json | 1 + extensions/nextcloud-talk/setup-entry.ts | 5 + extensions/tlon/package.json | 1 + extensions/tlon/setup-entry.ts | 5 + scripts/copy-bundled-plugin-metadata.mjs | 12 ++ src/cli/program/preaction.test.ts | 27 +++ src/cli/program/preaction.ts | 12 +- src/commands/channels/add.ts | 5 +- src/commands/onboard-channels.ts | 45 ++++- .../onboarding/plugin-install.test.ts | 4 + src/commands/onboarding/plugin-install.ts | 1 + src/plugins/discovery.ts | 29 +++ src/plugins/loader.test.ts | 182 ++++++++++++++++++ src/plugins/loader.ts | 68 ++++++- src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 1 + tsdown.config.ts | 10 +- 30 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 extensions/bluebubbles/setup-entry.ts create mode 100644 extensions/feishu/setup-entry.ts create mode 100644 extensions/googlechat/setup-entry.ts create mode 100644 extensions/irc/setup-entry.ts create mode 100644 extensions/matrix/setup-entry.ts create mode 100644 extensions/msteams/setup-entry.ts create mode 100644 extensions/nextcloud-talk/setup-entry.ts create mode 100644 extensions/tlon/setup-entry.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4113c9fbd05..2a5b5d37006 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -749,7 +749,8 @@ A plugin directory may include a `package.json` with `openclaw.extensions`: { "name": "my-pack", "openclaw": { - "extensions": ["./src/safety.ts", "./src/tools.ts"] + "extensions": ["./src/safety.ts", "./src/tools.ts"], + "setupEntry": "./src/setup-entry.ts" } } ``` @@ -768,6 +769,12 @@ Security note: `openclaw plugins install` installs plugin dependencies with `npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency trees "pure JS/TS" and avoid packages that require `postinstall` builds. +Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it +loads `setupEntry` instead of the full plugin entry. This keeps startup and +onboarding lighter when your main plugin entry also wires tools, hooks, or +other runtime-only code. + ### Channel catalog metadata Channel plugins can advertise onboarding metadata via `openclaw.channel` and @@ -1657,6 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 67df516b8d7..2426958d346 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "bluebubbles", "label": "BlueBubbles", diff --git a/extensions/bluebubbles/setup-entry.ts b/extensions/bluebubbles/setup-entry.ts new file mode 100644 index 00000000000..5e05d9c8bb2 --- /dev/null +++ b/extensions/bluebubbles/setup-entry.ts @@ -0,0 +1,5 @@ +import { bluebubblesPlugin } from "./src/channel.js"; + +export default { + plugin: bluebubblesPlugin, +}; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 805dd389b0a..d5dfe64f369 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "feishu", "label": "Feishu", diff --git a/extensions/feishu/setup-entry.ts b/extensions/feishu/setup-entry.ts new file mode 100644 index 00000000000..3e4df4faee8 --- /dev/null +++ b/extensions/feishu/setup-entry.ts @@ -0,0 +1,5 @@ +import { feishuPlugin } from "./src/channel.js"; + +export default { + plugin: feishuPlugin, +}; diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 3514ac52b90..2c4469163db 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -19,6 +19,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "googlechat", "label": "Google Chat", diff --git a/extensions/googlechat/setup-entry.ts b/extensions/googlechat/setup-entry.ts new file mode 100644 index 00000000000..7d80304ccf3 --- /dev/null +++ b/extensions/googlechat/setup-entry.ts @@ -0,0 +1,6 @@ +import { googlechatDock, googlechatPlugin } from "./src/channel.js"; + +export default { + plugin: googlechatPlugin, + dock: googlechatDock, +}; diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 8d162b9ac20..774fa993dbd 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -9,6 +9,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/irc/setup-entry.ts b/extensions/irc/setup-entry.ts new file mode 100644 index 00000000000..fe8bea1814d --- /dev/null +++ b/extensions/irc/setup-entry.ts @@ -0,0 +1,5 @@ +import { ircPlugin } from "./src/channel.js"; + +export default { + plugin: ircPlugin, +}; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 5b973b88635..bebd410fae9 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -15,6 +15,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "matrix", "label": "Matrix", diff --git a/extensions/matrix/setup-entry.ts b/extensions/matrix/setup-entry.ts new file mode 100644 index 00000000000..4cbabfe6333 --- /dev/null +++ b/extensions/matrix/setup-entry.ts @@ -0,0 +1,5 @@ +import { matrixPlugin } from "./src/channel.js"; + +export default { + plugin: matrixPlugin, +}; diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 4784334d1d5..eb02c9cee13 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "msteams", "label": "Microsoft Teams", diff --git a/extensions/msteams/setup-entry.ts b/extensions/msteams/setup-entry.ts new file mode 100644 index 00000000000..fb850b60e18 --- /dev/null +++ b/extensions/msteams/setup-entry.ts @@ -0,0 +1,5 @@ +import { msteamsPlugin } from "./src/channel.js"; + +export default { + plugin: msteamsPlugin, +}; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c217d0f0ce7..d594a67b96f 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nextcloud-talk", "label": "Nextcloud Talk", diff --git a/extensions/nextcloud-talk/setup-entry.ts b/extensions/nextcloud-talk/setup-entry.ts new file mode 100644 index 00000000000..f33df37c7dc --- /dev/null +++ b/extensions/nextcloud-talk/setup-entry.ts @@ -0,0 +1,5 @@ +import { nextcloudTalkPlugin } from "./src/channel.js"; + +export default { + plugin: nextcloudTalkPlugin, +}; diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 40ec9aeedde..071280374a3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "tlon", "label": "Tlon", diff --git a/extensions/tlon/setup-entry.ts b/extensions/tlon/setup-entry.ts new file mode 100644 index 00000000000..667e917c8da --- /dev/null +++ b/extensions/tlon/setup-entry.ts @@ -0,0 +1,5 @@ +import { tlonPlugin } from "./src/channel.js"; + +export default { + plugin: tlonPlugin, +}; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 426f319c02c..f5eac7ba513 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -23,6 +23,15 @@ export function rewritePackageExtensions(entries) { }); } +function rewritePackageEntry(entry) { + if (typeof entry !== "string" || entry.trim().length === 0) { + return undefined; + } + const normalized = entry.replace(/^\.\//, ""); + const rewritten = normalized.replace(/\.[^.]+$/u, ".js"); + return `./${rewritten}`; +} + function ensurePathInsideRoot(rootDir, rawPath) { const resolved = path.resolve(rootDir, rawPath); const relative = path.relative(rootDir, resolved); @@ -176,6 +185,9 @@ export function copyBundledPluginMetadata(params = {}) { packageJson.openclaw = { ...packageJson.openclaw, extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + ...(typeof packageJson.openclaw.setupEntry === "string" + ? { setupEntry: rewritePackageEntry(packageJson.openclaw.setupEntry) } + : {}), }; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2376e97100f..7b8fe8b878a 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -91,6 +91,8 @@ describe("registerPreActionHooks", () => { program.command("agents").action(() => {}); program.command("configure").action(() => {}); program.command("onboard").action(() => {}); + const channels = program.command("channels"); + channels.command("add").action(() => {}); program .command("update") .command("status") @@ -167,6 +169,31 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); }); + it("keeps onboarding and channels add manifest-first", async () => { + await runPreAction({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["onboard"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + await runPreAction({ + parseArgv: ["channels", "add"], + processArgv: ["node", "openclaw", "channels", "add"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["channels", "add"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("skips help/version preaction and respects banner opt-out", async () => { await runPreAction({ parseArgv: ["status"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 19659f97c7e..edeec669079 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -32,7 +32,6 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "directory", "agents", "configure", - "onboard", "status", "health", ]); @@ -72,15 +71,19 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { } function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { - if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + const [primary, secondary] = commandPath; + if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) { return false; } - if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + if ((primary === "status" || primary === "health") && hasFlag(argv, "--json")) { + return false; + } + // Onboarding/setup should stay manifest-first and load selected plugins on demand. + if (primary === "onboard" || (primary === "channels" && secondary === "add")) { return false; } return true; } - function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -148,6 +151,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access + if (shouldLoadPluginsForCommand(commandPath, argv)) { if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index e412c60215a..88e1a245906 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -195,7 +195,10 @@ export async function channelsAddCommand( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - return snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin; + return ( + snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin + ); }; if (!channel && catalogEntry) { diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 81deb95e901..cdb987914bc 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -123,11 +124,16 @@ async function collectChannelStatus(params: { installedPlugins?: ReturnType; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); - const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), + const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedChannelIds = new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: process.env, + }).plugins.flatMap((plugin) => plugin.channels), ); + const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ @@ -151,6 +157,28 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); + const discoveredPluginStatuses = allCatalogEntries + .filter((entry) => installedChannelIds.has(entry.id)) + .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) + .map((entry) => { + const configured = isChannelConfigured(params.cfg, entry.id); + const pluginEnabled = + params.cfg.plugins?.entries?.[entry.pluginId ?? entry.id]?.enabled !== false; + const statusLabel = configured + ? pluginEnabled + ? "configured" + : "configured (plugin disabled)" + : pluginEnabled + ? "installed" + : "installed (plugin disabled)"; + return { + channel: entry.id as ChannelChoice, + configured, + statusLines: [`${entry.meta.label}: ${statusLabel}`], + selectionHint: statusLabel, + quickstartScore: 0, + }; + }); const catalogStatuses = catalogEntries.map((entry) => ({ channel: entry.id, configured: false, @@ -158,7 +186,12 @@ async function collectChannelStatus(params: { selectionHint: "plugin · install", quickstartScore: 0, })); - const combinedStatuses = [...statusEntries, ...fallbackStatuses, ...catalogStatuses]; + const combinedStatuses = [ + ...statusEntries, + ...fallbackStatuses, + ...discoveredPluginStatuses, + ...catalogStatuses, + ]; const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { @@ -344,7 +377,9 @@ export async function setupChannels( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; + const plugin = + snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); } diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 1cd9e530b86..953fccf5a68 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -292,6 +292,7 @@ describe("ensureOnboardingPluginInstalled", () => { config: cfg, workspaceDir: "/tmp/openclaw-workspace", cache: false, + includeSetupOnlyChannelPlugins: true, }), ); expect(clearPluginDiscoveryCache.mock.invocationCallOrder[0]).toBeLessThan( @@ -316,6 +317,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, }), ); }); @@ -377,6 +379,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); @@ -400,6 +403,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["@openclaw/msteams-plugin"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 31f5ec1d64d..3a7f5623425 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -250,6 +250,7 @@ function loadOnboardingPluginRegistry(params: { cache: false, logger: createPluginLoaderLogger(log), onlyPluginIds: params.onlyPluginIds, + includeSetupOnlyChannelPlugins: true, activate: params.activate, }); } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c102ffc80c7..743b0b569f9 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -19,6 +19,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); export type PluginCandidate = { idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -355,6 +356,7 @@ function addCandidate(params: { seen: Set; idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -385,6 +387,7 @@ function addCandidate(params: { params.candidates.push({ idHint: params.idHint, source: resolved, + setupSource: params.setupSource, rootDir: resolvedRoot, origin: params.origin, format: params.format ?? "openclaw", @@ -520,6 +523,17 @@ function discoverInDirectory(params: { const manifest = readPackageManifest(fullPath, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: fullPath, + entryPath: setupEntryPath, + sourceLabel: fullPath, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -543,6 +557,7 @@ function discoverInDirectory(params: { hasMultipleExtensions: extensions.length > 1, }), source: resolved, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -577,6 +592,7 @@ function discoverInDirectory(params: { seen: params.seen, idHint: entry.name, source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -637,6 +653,17 @@ function discoverFromPath(params: { const manifest = readPackageManifest(resolved, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: resolved, + entryPath: setupEntryPath, + sourceLabel: resolved, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -660,6 +687,7 @@ function discoverFromPath(params: { hasMultipleExtensions: extensions.length > 1, }), source, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, @@ -695,6 +723,7 @@ function discoverFromPath(params: { seen: params.seen, idHint: path.basename(resolved), source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0460e481b25..fb6805667cb 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1703,6 +1703,188 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(disabled?.status).toBe("disabled"); }); + it("skips disabled channel imports unless setup-only loading is explicitly enabled", () => { + useNoBundledPlugins(); + const marker = path.join(makeTempDir(), "lazy-channel-imported.txt"); + const plugin = writePlugin({ + id: "lazy-channel", + filename: "lazy-channel.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8"); +module.exports = { + id: "lazy-channel", + register(api) { + api.registerChannel({ + plugin: { + id: "lazy-channel", + meta: { + id: "lazy-channel", + label: "Lazy Channel", + selectionLabel: "Lazy Channel", + docsPath: "/channels/lazy-channel", + blurb: "lazy test channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "lazy-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["lazy-channel"], + }, + null, + 2, + ), + "utf-8", + ); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: ["lazy-channel"], + entries: { + "lazy-channel": { enabled: false }, + }, + }, + }; + + const registry = loadOpenClawPlugins({ + cache: false, + config, + }); + + expect(fs.existsSync(marker)).toBe(false); + expect(registry.channelSetups).toHaveLength(0); + expect(registry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe("disabled"); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(marker)).toBe(true); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + expect(setupRegistry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe( + "disabled", + ); + }); + + it("uses package setupEntry for setup-only channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-entry-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-entry-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "full entry should not run in setup-only mode", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-test"], + entries: { + "setup-entry-test": { enabled: false }, + }, + }, + }, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 13f6842d1e1..40fd3e36cfd 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -51,6 +53,7 @@ export type PluginLoadOptions = { cache?: boolean; mode?: "full" | "validate"; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; activate?: boolean; }; @@ -244,6 +247,7 @@ function buildCacheKey(params: { installs?: Record; env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -267,11 +271,12 @@ function buildCacheKey(params: { ]), ); const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); + const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}`; + })}::${scopeKey}::${setupOnlyKey}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -326,6 +331,32 @@ function resolvePluginModuleExport(moduleExport: unknown): { return {}; } +function resolveSetupChannelRegistration(moduleExport: unknown): { + plugin?: ChannelPlugin; + dock?: ChannelDock; +} { + const resolved = + moduleExport && + typeof moduleExport === "object" && + "default" in (moduleExport as Record) + ? (moduleExport as { default: unknown }).default + : moduleExport; + if (!resolved || typeof resolved !== "object") { + return {}; + } + const setup = resolved as { + plugin?: unknown; + dock?: unknown; + }; + if (!setup.plugin || typeof setup.plugin !== "object") { + return {}; + } + return { + plugin: setup.plugin as ChannelPlugin, + ...(setup.dock && typeof setup.dock === "object" ? { dock: setup.dock as ChannelDock } : {}), + }; +} + function createPluginRecord(params: { id: string; name?: string; @@ -669,6 +700,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const normalized = normalizePluginsConfig(cfg.plugins); const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const shouldActivate = options.activate !== false; // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they @@ -680,6 +712,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi installs: cfg.plugins?.installs, env, onlyPluginIds, + includeSetupOnlyChannelPlugins, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -892,7 +925,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const registrationMode = enableState.enabled ? "full" - : !validateOnly && manifestRecord.channels.length > 0 + : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -960,8 +993,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + const loadSource = + registrationMode === "setup-only" && manifestRecord.setupSource + ? manifestRecord.setupSource + : candidate.source; const opened = openBoundaryFileSync({ - absolutePath: candidate.source, + absolutePath: loadSource, rootPath: pluginRoot, boundaryLabel: "plugin root", rejectHardlinks: candidate.origin !== "bundled", @@ -992,6 +1029,31 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + if (registrationMode === "setup-only" && manifestRecord.setupSource) { + const setupRegistration = resolveSetupChannelRegistration(mod); + if (setupRegistration.plugin) { + if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, + ); + continue; + } + const api = createApi(record, { + config: cfg, + pluginConfig: {}, + hookPolicy: entry?.hooks, + registrationMode, + }); + api.registerChannel({ + plugin: setupRegistration.plugin, + ...(setupRegistration.dock ? { dock: setupRegistration.dock } : {}), + }); + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + } + const resolved = resolvePluginModuleExport(mod); const definition = resolved.definition; const register = resolved.register; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 48fdae50d95..2c24b87f541 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -48,6 +48,7 @@ export type PluginManifestRecord = { workspaceDir?: string; rootDir: string; source: string; + setupSource?: string; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -158,6 +159,7 @@ function buildRecord(params: { workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, source: params.candidate.source, + setupSource: params.candidate.setupSource, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..0cbdd9264f3 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -148,6 +148,7 @@ export type PluginPackageInstall = { export type OpenClawPackageManifest = { extensions?: string[]; + setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; }; diff --git a/tsdown.config.ts b/tsdown.config.ts index 2b7c9dbe192..b266f660421 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -124,13 +124,21 @@ function listBundledPluginBuildEntries(): Record { if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { extensions?: unknown }; + openclaw?: { extensions?: unknown; setupEntry?: unknown }; }; packageEntries = Array.isArray(packageJson.openclaw?.extensions) ? packageJson.openclaw.extensions.filter( (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ) : []; + const setupEntry = + typeof packageJson.openclaw?.setupEntry === "string" && + packageJson.openclaw.setupEntry.trim().length > 0 + ? packageJson.openclaw.setupEntry + : undefined; + if (setupEntry) { + packageEntries = Array.from(new Set([...packageEntries, setupEntry])); + } } catch { packageEntries = []; } From 57a0534f937e9fbdc69e9804d859f5651b6f2dbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:47:23 -0700 Subject: [PATCH 026/331] fix(cli): repair preaction merge typo --- src/cli/program/preaction.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index edeec669079..6e869a23215 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -151,7 +151,6 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access - if (shouldLoadPluginsForCommand(commandPath, argv)) { if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); From 399b6f745a508e46911079aba187f318c9e08166 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:51:38 -0700 Subject: [PATCH 027/331] Signal: restore setup surface helper exports --- extensions/signal/src/setup-surface.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 51dbbd5625a..822df4caf10 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -181,3 +181,5 @@ export const signalSetupWizard: ChannelSetupWizard = { dmPolicy: signalDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; From 413d2ff3da8e4dd7c2b12845a0f12f710f89c8f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:52:22 -0700 Subject: [PATCH 028/331] iMessage: lazy-load setup wizard surface --- extensions/imessage/src/channel.runtime.ts | 1 + extensions/imessage/src/channel.ts | 10 +- extensions/imessage/src/setup-core.ts | 236 +++++++++++++++++++++ extensions/imessage/src/setup-surface.ts | 111 +--------- src/plugin-sdk/imessage.ts | 6 +- src/plugin-sdk/index.ts | 6 +- 6 files changed, 254 insertions(+), 116 deletions(-) create mode 100644 extensions/imessage/src/channel.runtime.ts create mode 100644 extensions/imessage/src/setup-core.ts diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts new file mode 100644 index 00000000000..81229e49ff9 --- /dev/null +++ b/extensions/imessage/src/channel.runtime.ts @@ -0,0 +1 @@ +export { imessageSetupWizard } from "./setup-surface.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 5760d1c2fb3..f2621dea5c2 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -28,10 +28,18 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; -import { imessageSetupAdapter, imessageSetupWizard } from "./setup-surface.js"; +import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; const meta = getChatChannelMeta("imessage"); +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts new file mode 100644 index 00000000000..69a8072bd59 --- /dev/null +++ b/extensions/imessage/src/setup-core.ts @@ -0,0 +1,236 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "./accounts.js"; +import { normalizeIMessageHandle } from "./targets.js"; + +const channel = "imessage" as const; + +export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + const lower = entry.toLowerCase(); + if (lower.startsWith("chat_id:")) { + const id = entry.slice("chat_id:".length).trim(); + if (!/^\d+$/.test(id)) { + return { error: `Invalid chat_id: ${entry}` }; + } + return { value: entry }; + } + if (lower.startsWith("chat_guid:")) { + if (!entry.slice("chat_guid:".length).trim()) { + return { error: "Invalid chat_guid entry" }; + } + return { value: entry }; + } + if (lower.startsWith("chat_identifier:")) { + if (!entry.slice("chat_identifier:".length).trim()) { + return { error: "Invalid chat_identifier entry" }; + } + return { value: entry }; + } + if (!normalizeIMessageHandle(entry)) { + return { error: `Invalid handle: ${entry}` }; + } + return { value: entry }; + }); +} + +function buildIMessageSetupPatch(input: { + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; +}) { + return { + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }; +} + +async function promptIMessageAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "iMessage allowlist", + noteLines: [ + "Allowlist iMessage DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:... or chat_identifier:...", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + message: "iMessage allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + parseEntries: parseIMessageAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +export const imessageSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + accounts: { + ...next.channels?.imessage?.accounts, + [accountId]: { + ...next.channels?.imessage?.accounts?.[accountId], + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export function createIMessageSetupWizardProxy( + loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, +) { + const imessageDmPolicy: ChannelOnboardingDmPolicy = { + label: "iMessage", + channel, + policyKey: "channels.imessage.dmPolicy", + allowFromKey: "channels.imessage.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptIMessageAllowFrom, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "imsg found", + unconfiguredHint: "imsg missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listIMessageAccountIds(cfg).some((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); + }), + resolveStatusLines: async (params) => + (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + }, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "imsg CLI path", + initialValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + currentValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + shouldPrompt: async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "iMessage", + helpLines: ["imsg CLI path required to enable iMessage."], + }, + ], + completionNote: { + title: "iMessage next steps", + lines: [ + "This is still a work in progress.", + "Ensure OpenClaw has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + }, + dmPolicy: imessageDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 69382ff4014..90fcf648e60 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -5,15 +5,10 @@ import { setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -21,53 +16,10 @@ import { resolveDefaultIMessageAccountId, resolveIMessageAccount, } from "./accounts.js"; -import { normalizeIMessageHandle } from "./targets.js"; +import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; const channel = "imessage" as const; -export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - const lower = entry.toLowerCase(); - if (lower.startsWith("chat_id:")) { - const id = entry.slice("chat_id:".length).trim(); - if (!/^\d+$/.test(id)) { - return { error: `Invalid chat_id: ${entry}` }; - } - return { value: entry }; - } - if (lower.startsWith("chat_guid:")) { - if (!entry.slice("chat_guid:".length).trim()) { - return { error: "Invalid chat_guid entry" }; - } - return { value: entry }; - } - if (lower.startsWith("chat_identifier:")) { - if (!entry.slice("chat_identifier:".length).trim()) { - return { error: "Invalid chat_identifier entry" }; - } - return { value: entry }; - } - if (!normalizeIMessageHandle(entry)) { - return { error: `Invalid handle: ${entry}` }; - } - return { value: entry }; - }); -} - -function buildIMessageSetupPatch(input: { - cliPath?: string; - dbPath?: string; - service?: "imessage" | "sms" | "auto"; - region?: string; -}) { - return { - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), - }; -} - async function promptIMessageAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -113,63 +65,6 @@ const imessageDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptIMessageAllowFrom, }; -export const imessageSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - accounts: { - ...next.channels?.imessage?.accounts, - [accountId]: { - ...next.channels?.imessage?.accounts?.[accountId], - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; - export const imessageSetupWizard: ChannelSetupWizard = { channel, status: { @@ -236,3 +131,5 @@ export const imessageSetupWizard: ChannelSetupWizard = { dmPolicy: imessageDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { imessageSetupAdapter, parseIMessageAllowFromEntries }; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 8c8727ef5d9..1d767798873 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -24,10 +24,8 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - imessageSetupAdapter, - imessageSetupWizard, -} from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 04d03c56f8e..2880a60ee58 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -706,10 +706,8 @@ export { resolveIMessageAccount, type ResolvedIMessageAccount, } from "../../extensions/imessage/src/accounts.js"; -export { - imessageSetupAdapter, - imessageSetupWizard, -} from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, From 7d2ddf70c15bb2837bc78d89ec1be4a4cf038812 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:59:58 -0700 Subject: [PATCH 029/331] Nextcloud Talk: split setup adapter helpers --- extensions/nextcloud-talk/src/channel.ts | 3 +- extensions/nextcloud-talk/src/setup-core.ts | 235 ++++++++++++++++++ .../nextcloud-talk/src/setup-surface.ts | 148 +---------- 3 files changed, 247 insertions(+), 139 deletions(-) create mode 100644 extensions/nextcloud-talk/src/setup-core.ts diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index b6a2c2ad5ca..77ca7ed36f9 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -33,7 +33,8 @@ import { import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; -import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; +import { nextcloudTalkSetupAdapter } from "./setup-core.js"; +import { nextcloudTalkSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; const meta = { diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts new file mode 100644 index 00000000000..9deafc5f71a --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -0,0 +1,235 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; + +const channel = "nextcloud-talk" as const; + +type NextcloudSetupInput = ChannelSetupInput & { + baseUrl?: string; + secret?: string; + secretFile?: string; +}; +type NextcloudTalkSection = NonNullable["nextcloud-talk"]; + +export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { + return value?.trim().replace(/\/+$/, "") ?? ""; +} + +export function validateNextcloudTalkBaseUrl(value: string): string | undefined { + if (!value) { + return "Required"; + } + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return "URL must start with http:// or https://"; + } + return undefined; +} + +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +export function setNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, + updates: Record, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: updates, + }) as CoreConfig; +} + +export function clearNextcloudTalkAccountFields( + cfg: CoreConfig, + accountId: string, + fields: string[], +): CoreConfig { + const section = cfg.channels?.["nextcloud-talk"]; + if (!section) { + return cfg; + } + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextSection = { ...section } as Record; + for (const field of fields) { + delete nextSection[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": nextSection as NextcloudTalkSection, + }, + } as CoreConfig; + } + + const currentAccount = section.accounts?.[accountId]; + if (!currentAccount) { + return cfg; + } + + const nextAccount = { ...currentAccount } as Record; + for (const field of fields) { + delete nextAccount[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": { + ...section, + accounts: { + ...section.accounts, + [accountId]: nextAccount as NonNullable[string], + }, + }, + }, + } as CoreConfig; +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await params.prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = String(entry) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (resolvedIds.length === 0) { + await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); + } + } + + return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existingAllowFrom.map((value) => String(value).trim().toLowerCase()), + resolvedIds, + ), + }); +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), + }); + return await promptNextcloudTalkAllowFrom({ + cfg: params.cfg as CoreConfig, + prompter: params.prompter, + accountId, + }); +} + +const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!setupInput.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const next = setupInput.useEnv + ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ + "botSecret", + "botSecretFile", + ]) + : namedConfig; + const patch = { + baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), + ...(setupInput.useEnv + ? {} + : setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }; + return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); + }, +}; diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 758ae4d3214..4fcb874b5d3 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -5,16 +5,11 @@ import { setOnboardingChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -22,32 +17,18 @@ import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, } from "./accounts.js"; +import { + clearNextcloudTalkAccountFields, + nextcloudTalkSetupAdapter, + normalizeNextcloudTalkBaseUrl, + setNextcloudTalkAccountConfig, + validateNextcloudTalkBaseUrl, +} from "./setup-core.js"; import type { CoreConfig, DmPolicy } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; -type NextcloudSetupInput = ChannelSetupInput & { - baseUrl?: string; - secret?: string; - secretFile?: string; -}; -type NextcloudTalkSection = NonNullable["nextcloud-talk"]; - -function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { - return value?.trim().replace(/\/+$/, "") ?? ""; -} - -function validateNextcloudTalkBaseUrl(value: string): string | undefined { - if (!value) { - return "Required"; - } - if (!value.startsWith("http://") && !value.startsWith("https://")) { - return "URL must start with http:// or https://"; - } - return undefined; -} - function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, @@ -56,67 +37,6 @@ function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConf }) as CoreConfig; } -function setNextcloudTalkAccountConfig( - cfg: CoreConfig, - accountId: string, - updates: Record, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: updates, - }) as CoreConfig; -} - -function clearNextcloudTalkAccountFields( - cfg: CoreConfig, - accountId: string, - fields: string[], -): CoreConfig { - const section = cfg.channels?.["nextcloud-talk"]; - if (!section) { - return cfg; - } - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextSection = { ...section } as Record; - for (const field of fields) { - delete nextSection[field]; - } - return { - ...cfg, - channels: { - ...(cfg.channels ?? {}), - "nextcloud-talk": nextSection as NextcloudTalkSection, - }, - } as CoreConfig; - } - - const currentAccount = section.accounts?.[accountId]; - if (!currentAccount) { - return cfg; - } - - const nextAccount = { ...currentAccount } as Record; - for (const field of fields) { - delete nextAccount[field]; - } - return { - ...cfg, - channels: { - ...(cfg.channels ?? {}), - "nextcloud-talk": { - ...section, - accounts: { - ...section.accounts, - [accountId]: nextAccount as NonNullable[string], - }, - }, - }, - } as CoreConfig; -} - async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -186,56 +106,6 @@ const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptNextcloudTalkAllowFromForAccount, }; -export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; - } - if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { - return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; - } - if (!setupInput.baseUrl) { - return "Nextcloud Talk requires --base-url."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: setupInput.name, - }); - const next = setupInput.useEnv - ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ - "botSecret", - "botSecretFile", - ]) - : namedConfig; - const patch = { - baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), - ...(setupInput.useEnv - ? {} - : setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }; - return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); - }, -}; - export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", @@ -404,3 +274,5 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { dmPolicy: nextcloudTalkDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { nextcloudTalkSetupAdapter }; From 70a6d40d37efb01debf7ddac8dd3debc9cf89651 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:10:28 +0000 Subject: [PATCH 030/331] fix: remove stale dist plugin dirs --- scripts/copy-bundled-plugin-metadata.mjs | 11 +----- .../copy-bundled-plugin-metadata.test.ts | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index f5eac7ba513..b4be20dfae4 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -135,13 +135,6 @@ export function copyBundledPluginMetadata(params = {}) { } const sourcePluginDirs = new Set(); - const removeGeneratedPluginArtifacts = (distPluginDir) => { - removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); - removeFileIfExists(path.join(distPluginDir, "package.json")); - removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); - removePathIfExists(path.join(distPluginDir, "node_modules")); - }; - for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { if (!dirent.isDirectory()) { continue; @@ -154,7 +147,7 @@ export function copyBundledPluginMetadata(params = {}) { const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json"); const distPackageJsonPath = path.join(distPluginDir, "package.json"); if (!fs.existsSync(manifestPath)) { - removeGeneratedPluginArtifacts(distPluginDir); + removePathIfExists(distPluginDir); continue; } @@ -203,7 +196,7 @@ export function copyBundledPluginMetadata(params = {}) { continue; } const distPluginDir = path.join(distExtensionsRoot, dirent.name); - removeGeneratedPluginArtifacts(distPluginDir); + removePathIfExists(distPluginDir); } } diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 88da85b0dda..8f4187a8937 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -258,6 +258,11 @@ describe("copyBundledPluginMetadata", () => { "node_modules", ); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "dist", "extensions", "removed-plugin", "index.js"), + "export default {}\n", + "utf8", + ); writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), { id: "removed-plugin", configSchema: { type: "object" }, @@ -270,17 +275,26 @@ describe("copyBundledPluginMetadata", () => { copyBundledPluginMetadata({ repoRoot }); - expect( - fs.existsSync( - path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), - ), - ).toBe(false); - expect( - fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json")), - ).toBe(false); - expect( - fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "bundled-skills")), - ).toBe(false); - expect(fs.existsSync(staleNodeModulesDir)).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin"))).toBe(false); + }); + + it("removes stale dist outputs when a source extension directory no longer has a manifest", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-manifestless-source-"); + const sourcePluginDir = path.join(repoRoot, "extensions", "google-gemini-cli-auth"); + fs.mkdirSync(path.join(sourcePluginDir, "node_modules"), { recursive: true }); + const staleDistDir = path.join(repoRoot, "dist", "extensions", "google-gemini-cli-auth"); + fs.mkdirSync(staleDistDir, { recursive: true }); + fs.writeFileSync(path.join(staleDistDir, "index.js"), "export default {}\n", "utf8"); + writeJson(path.join(staleDistDir, "openclaw.plugin.json"), { + id: "google-gemini-cli-auth", + configSchema: { type: "object" }, + }); + writeJson(path.join(staleDistDir, "package.json"), { + name: "@openclaw/google-gemini-cli-auth", + }); + + copyBundledPluginMetadata({ repoRoot }); + + expect(fs.existsSync(staleDistDir)).toBe(false); }); }); From 4ed30abc7ac2620ce7c5292fbede3b11b80da7e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:10:22 -0700 Subject: [PATCH 031/331] BlueBubbles: split setup adapter helpers --- extensions/bluebubbles/src/channel.ts | 3 +- extensions/bluebubbles/src/setup-core.ts | 84 ++++++++++++++++++++ extensions/bluebubbles/src/setup-surface.ts | 87 ++------------------- 3 files changed, 94 insertions(+), 80 deletions(-) create mode 100644 extensions/bluebubbles/src/setup-core.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index a482632ebea..d6d1a3130fb 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -31,7 +31,8 @@ import { resolveBlueBubblesMessageId } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; -import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js"; +import { blueBubblesSetupAdapter } from "./setup-core.js"; +import { blueBubblesSetupWizard } from "./setup-surface.js"; import { extractHandleFromChatGuid, looksLikeBlueBubblesTargetId, diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts new file mode 100644 index 00000000000..930fa29a64e --- /dev/null +++ b/extensions/bluebubbles/src/setup-core.ts @@ -0,0 +1,84 @@ +import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; + +const channel = "bluebubbles" as const; + +export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +export function setBlueBubblesAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom: string[], +): OpenClawConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { allowFrom }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const blueBubblesSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, + }, + onlyDefinedFields: true, + }); + }, +}; diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index 0cb23998663..f4ee2d98db4 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -2,18 +2,11 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/on import { mergeAllowFromEntries, resolveOnboardingAccountId, - setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -24,35 +17,17 @@ import { import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; +import { + blueBubblesSetupAdapter, + setBlueBubblesAllowFrom, + setBlueBubblesDmPolicy, +} from "./setup-core.js"; import { parseBlueBubblesAllowTarget } from "./targets.js"; import { normalizeBlueBubblesServerUrl } from "./types.js"; const channel = "bluebubbles" as const; const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; -function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setBlueBubblesAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { allowFrom }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - function parseBlueBubblesAllowFromInput(raw: string): string[] { return raw .split(/[\n,]+/g) @@ -183,54 +158,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptBlueBubblesAllowFrom, }; -export const blueBubblesSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl: input.httpUrl, - password: input.password, - webhookPath: input.webhookPath, - }, - onlyDefinedFields: true, - }); - }, -}; - export const blueBubblesSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", @@ -383,3 +310,5 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = { }, }), }; + +export { blueBubblesSetupAdapter }; From 6b28668104bb67fc7c689763d89e676c42b51235 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:21:49 +0000 Subject: [PATCH 032/331] test(plugins): cover retired google auth compatibility --- extensions/google/gemini-cli-provider.test.ts | 20 ++++++++- src/config/config.plugin-validation.test.ts | 41 +++++++++++++++++++ src/config/validation.ts | 2 +- src/plugins/providers.test.ts | 24 +++++++++-- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index ad5969c7c4d..dd991e2b32d 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -8,22 +8,33 @@ import googlePlugin from "./index.js"; function registerGooglePlugin(): { provider: ProviderPlugin; + webSearchProvider: { + id: string; + envVars: string[]; + label: string; + } | null; webSearchProviderRegistered: boolean; } { let provider: ProviderPlugin | undefined; let webSearchProviderRegistered = false; + let webSearchProvider: { + id: string; + envVars: string[]; + label: string; + } | null = null; googlePlugin.register({ registerProvider(nextProvider: ProviderPlugin) { provider = nextProvider; }, - registerWebSearchProvider() { + registerWebSearchProvider(nextProvider: { id: string; envVars: string[]; label: string }) { webSearchProviderRegistered = true; + webSearchProvider = nextProvider; }, } as never); if (!provider) { throw new Error("provider registration missing"); } - return { provider, webSearchProviderRegistered }; + return { provider, webSearchProviderRegistered, webSearchProvider }; } describe("google plugin", () => { @@ -32,6 +43,11 @@ describe("google plugin", () => { expect(result.provider.id).toBe("google-gemini-cli"); expect(result.webSearchProviderRegistered).toBe(true); + expect(result.webSearchProvider).toMatchObject({ + id: "gemini", + label: "Gemini (Google Search)", + envVars: ["GEMINI_API_KEY"], + }); }); it("owns gemini 3.1 forward-compat resolution", () => { diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index efb84acdacf..42d473aed4e 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -281,6 +281,47 @@ describe("config plugin validation", () => { } }); + it("warns for removed google gemini auth plugin ids instead of failing validation", async () => { + const removedId = "google-gemini-cli-auth"; + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: false, + entries: { [removedId]: { enabled: true } }, + allow: [removedId], + deny: [removedId], + slots: { memory: removedId }, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.warnings).toEqual( + expect.arrayContaining([ + { + path: `plugins.entries.${removedId}`, + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.allow", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.deny", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.slots.memory", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + ]), + ); + } + }); + it("surfaces plugin config diagnostics", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, diff --git a/src/config/validation.ts b/src/config/validation.ts index e97bd8cbedf..2a2c08b96ee 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -24,7 +24,7 @@ import { findLegacyConfigIssues } from "./legacy.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; -const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); +const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]); type UnknownIssueRecord = Record; type AllowedValuesCollection = { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 4e238c2193d..86ffb8e5ffc 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -11,7 +11,7 @@ describe("resolvePluginProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ - providers: [{ provider: { id: "demo-provider" } }], + providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], }); }); @@ -23,7 +23,7 @@ describe("resolvePluginProviders", () => { env, }); - expect(providers).toEqual([{ id: "demo-provider" }]); + expect(providers).toEqual([{ id: "demo-provider", pluginId: "google" }]); expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: "/workspace/explicit", @@ -46,13 +46,12 @@ describe("resolvePluginProviders", () => { expect.objectContaining({ config: expect.objectContaining({ plugins: expect.objectContaining({ - allow: expect.arrayContaining(["openrouter", "kilocode", "moonshot"]), + allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]), }), }), }), ); }); - it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => { resolvePluginProviders({ env: { VITEST: "1" } as NodeJS.ProcessEnv, @@ -70,4 +69,21 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("does not reintroduce the retired google auth plugin id into compat allowlists", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + }); + + const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0]; + const allow = call?.config?.plugins?.allow; + + expect(allow).toContain("google"); + expect(allow).not.toContain("google-gemini-cli-auth"); + }); }); From bb76a90dd1843af801798d64f9c35c31d6118e15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:23:45 +0000 Subject: [PATCH 033/331] refactor(tests): share plugin registration helpers --- extensions/anthropic/index.test.ts | 15 +------ extensions/github-copilot/index.test.ts | 15 +------ extensions/google/gemini-cli-provider.test.ts | 34 +++++++-------- extensions/zai/index.test.ts | 15 +------ src/test-utils/plugin-registration.ts | 41 +++++++++++++++++++ 5 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 src/test-utils/plugin-registration.ts diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 00fe6ba74ee..172a7099e4d 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; import anthropicPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - anthropicPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(anthropicPlugin); describe("anthropic plugin", () => { it("owns anthropic 4.6 forward-compat resolution", () => { diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index e69fee13b88..633d1f1ad75 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -1,19 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import githubCopilotPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - githubCopilotPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(githubCopilotPlugin); describe("github-copilot plugin", () => { it("owns Copilot-specific forward-compat fallbacks", () => { diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index dd991e2b32d..341ecd9e0b9 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { createCapturedPluginRegistration } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, @@ -15,26 +16,25 @@ function registerGooglePlugin(): { } | null; webSearchProviderRegistered: boolean; } { - let provider: ProviderPlugin | undefined; - let webSearchProviderRegistered = false; - let webSearchProvider: { - id: string; - envVars: string[]; - label: string; - } | null = null; - googlePlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - registerWebSearchProvider(nextProvider: { id: string; envVars: string[]; label: string }) { - webSearchProviderRegistered = true; - webSearchProvider = nextProvider; - }, - } as never); + const captured = createCapturedPluginRegistration(); + googlePlugin.register(captured.api); + const provider = captured.providers[0]; if (!provider) { throw new Error("provider registration missing"); } - return { provider, webSearchProviderRegistered, webSearchProvider }; + const webSearchProvider = captured.webSearchProviders[0] ?? null; + return { + provider, + webSearchProviderRegistered: webSearchProvider !== null, + webSearchProvider: + webSearchProvider === null + ? null + : { + id: webSearchProvider.id, + envVars: webSearchProvider.envVars, + label: webSearchProvider.label, + }, + }; } describe("google plugin", () => { diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts index 119309d31a3..f79f53670b7 100644 --- a/extensions/zai/index.test.ts +++ b/extensions/zai/index.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; import zaiPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - zaiPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(zaiPlugin); describe("zai plugin", () => { it("owns glm-5 forward-compat resolution", () => { diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts new file mode 100644 index 00000000000..fe89acc5d92 --- /dev/null +++ b/src/test-utils/plugin-registration.ts @@ -0,0 +1,41 @@ +import type { + OpenClawPluginApi, + ProviderPlugin, + WebSearchProviderPlugin, +} from "../plugins/types.js"; + +export type CapturedPluginRegistration = { + api: OpenClawPluginApi; + providers: ProviderPlugin[]; + webSearchProviders: WebSearchProviderPlugin[]; +}; + +export function createCapturedPluginRegistration(): CapturedPluginRegistration { + const providers: ProviderPlugin[] = []; + const webSearchProviders: WebSearchProviderPlugin[] = []; + + return { + providers, + webSearchProviders, + api: { + registerProvider(provider: ProviderPlugin) { + providers.push(provider); + }, + registerWebSearchProvider(provider: WebSearchProviderPlugin) { + webSearchProviders.push(provider); + }, + } as OpenClawPluginApi, + }; +} + +export function registerSingleProviderPlugin(params: { + register(api: OpenClawPluginApi): void; +}): ProviderPlugin { + const captured = createCapturedPluginRegistration(); + params.register(captured.api); + const provider = captured.providers[0]; + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} From 7c0cac2740c8526598867fa39a3e28a638b8275a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:24:26 +0000 Subject: [PATCH 034/331] refactor(plugins): share bundled compat transforms --- src/plugins/bundled-compat.ts | 65 ++++++++++++++++++++++++ src/plugins/providers.ts | 39 ++------------- src/plugins/web-search-providers.ts | 76 +++++------------------------ 3 files changed, 82 insertions(+), 98 deletions(-) create mode 100644 src/plugins/bundled-compat.ts diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts new file mode 100644 index 00000000000..e946be355b5 --- /dev/null +++ b/src/plugins/bundled-compat.ts @@ -0,0 +1,65 @@ +import type { PluginEntryConfig } from "../config/types.plugins.js"; +import type { PluginLoadOptions } from "./loader.js"; + +export function withBundledPluginAllowlistCompat(params: { + config: PluginLoadOptions["config"]; + pluginIds: readonly string[]; +}): PluginLoadOptions["config"] { + const allow = params.config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return params.config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of params.pluginIds) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + allow: [...allowSet], + }, + }; +} + +export function withBundledPluginEnablementCompat(params: { + config: PluginLoadOptions["config"]; + pluginIds: readonly string[]; +}): PluginLoadOptions["config"] { + const existingEntries = params.config?.plugins?.entries ?? {}; + let changed = false; + const nextEntries: Record = { ...existingEntries }; + + for (const pluginId of params.pluginIds) { + if (existingEntries[pluginId] !== undefined) { + continue; + } + nextEntries[pluginId] = { enabled: true }; + changed = true; + } + + if (!changed) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + entries: { + ...existingEntries, + ...nextEntries, + }, + }, + }; +} diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 010766e5fa9..4f4216730cf 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,4 +1,5 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; +import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { ProviderPlugin } from "./types.js"; @@ -64,38 +65,6 @@ function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { return false; } -function withBundledProviderAllowlistCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const allow = config?.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return config; - } - - const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); - let changed = false; - for (const pluginId of BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (!allowSet.has(pluginId)) { - allowSet.add(pluginId); - changed = true; - } - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - // Backward compat: bundled implicit providers historically stayed - // available even when operators kept a restrictive plugin allowlist. - allow: [...allowSet], - }, - }; -} - function withBundledProviderVitestCompat(params: { config: PluginLoadOptions["config"]; env?: PluginLoadOptions["env"]; @@ -118,7 +87,6 @@ function withBundledProviderVitestCompat(params: { }, }; } - export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -129,7 +97,10 @@ export function resolvePluginProviders(params: { onlyPluginIds?: string[]; }): ProviderPlugin[] { const maybeAllowlistCompat = params.bundledProviderAllowlistCompat - ? withBundledProviderAllowlistCompat(params.config) + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) : params.config; const config = params.bundledProviderVitestCompat ? withBundledProviderVitestCompat({ diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 8120be0113c..f59cf95f51a 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,5 +1,8 @@ -import type { PluginEntryConfig } from "../config/types.plugins.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, +} from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { WebSearchProviderPlugin } from "./types.js"; @@ -14,67 +17,6 @@ const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "xai", ] as const; -function withBundledWebSearchAllowlistCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const allow = config?.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return config; - } - - const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); - let changed = false; - for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (!allowSet.has(pluginId)) { - allowSet.add(pluginId); - changed = true; - } - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - allow: [...allowSet], - }, - }; -} - -function withBundledWebSearchEnablementCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const existingEntries = config?.plugins?.entries ?? {}; - let changed = false; - const nextEntries: Record = { ...existingEntries }; - - for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (existingEntries[pluginId] !== undefined) { - continue; - } - nextEntries[pluginId] = { enabled: true }; - changed = true; - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - entries: { - ...existingEntries, - ...nextEntries, - }, - }, - }; -} - export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -82,9 +24,15 @@ export function resolvePluginWebSearchProviders(params: { bundledAllowlistCompat?: boolean; }): WebSearchProviderPlugin[] { const allowlistCompat = params.bundledAllowlistCompat - ? withBundledWebSearchAllowlistCompat(params.config) + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) : params.config; - const config = withBundledWebSearchEnablementCompat(allowlistCompat); + const config = withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }); const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, From 92e765cdee15c445af94e0b2c2ac6d03f907f56f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:27:38 +0000 Subject: [PATCH 035/331] refactor(google): split oauth flow modules --- extensions/google/oauth.credentials.ts | 163 ++++++ extensions/google/oauth.flow.ts | 152 ++++++ extensions/google/oauth.http.ts | 24 + extensions/google/oauth.project.ts | 235 +++++++++ extensions/google/oauth.shared.ts | 44 ++ extensions/google/oauth.token.ts | 57 +++ extensions/google/oauth.ts | 671 +------------------------ 7 files changed, 688 insertions(+), 658 deletions(-) create mode 100644 extensions/google/oauth.credentials.ts create mode 100644 extensions/google/oauth.flow.ts create mode 100644 extensions/google/oauth.http.ts create mode 100644 extensions/google/oauth.project.ts create mode 100644 extensions/google/oauth.shared.ts create mode 100644 extensions/google/oauth.token.ts diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts new file mode 100644 index 00000000000..1c1e88db042 --- /dev/null +++ b/extensions/google/oauth.credentials.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); + + let content: string | null = null; + for (const geminiCliDir of geminiCliDirs) { + const searchPaths = [ + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ), + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "code_assist", + "oauth2.js", + ), + ]; + + for (const path of searchPaths) { + if (existsSync(path)) { + content = readFileSync(path, "utf8"); + break; + } + } + if (content) { + break; + } + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) { + content = readFileSync(found, "utf8"); + break; + } + } + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed + } + return null; +} + +function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { + const binDir = dirname(geminiPath); + const candidates = [ + dirname(dirname(resolvedPath)), + join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), + join(binDir, "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), + ]; + + const deduped: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const key = + process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(candidate); + } + return deduped; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const path = join(dir, name + ext); + if (existsSync(path)) { + return path; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isFile() && entry.name === name) { + return path; + } + if (entry.isDirectory() && !entry.name.startsWith(".")) { + const found = findFile(path, name, depth - 1); + if (found) { + return found; + } + } + } + } catch {} + return null; +} + +export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); +} diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts new file mode 100644 index 00000000000..00cab07dc68 --- /dev/null +++ b/extensions/google/oauth.flow.ts @@ -0,0 +1,152 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { isWSL2Sync } from "../../src/infra/wsl.js"; +import { resolveOAuthClientConfig } from "./oauth.credentials.js"; +import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; + +export function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} + +export function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function buildAuthUrl(challenge: string, verifier: string): string { + const { clientId } = resolveOAuthClientConfig(); + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + access_type: "offline", + prompt: "consent", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +export function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + return { code, state }; + } catch { + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +export async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = "localhost"; + const expectedPath = "/oauth2callback"; + + return new Promise<{ code: string; state: string }>((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Missing code or state"); + finish(new Error("Missing OAuth code or state")); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Invalid state"); + finish(new Error("OAuth state mismatch")); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + "" + + "

Gemini CLI OAuth complete

" + + "

You can close this window and return to OpenClaw.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error("OAuth callback failed")); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) { + clearTimeout(timeout); + } + try { + server.close(); + } catch { + // ignore close errors + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once("error", (err) => { + finish(err instanceof Error ? err : new Error("OAuth callback server error")); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); + }); + + timeout = setTimeout(() => { + finish(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }); +} diff --git a/extensions/google/oauth.http.ts b/extensions/google/oauth.http.ts new file mode 100644 index 00000000000..6c07c447143 --- /dev/null +++ b/extensions/google/oauth.http.ts @@ -0,0 +1,24 @@ +import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; +import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js"; + +export async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + timeoutMs, + }); + try { + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + await release(); + } +} diff --git a/extensions/google/oauth.project.ts b/extensions/google/oauth.project.ts new file mode 100644 index 00000000000..fa163b12f19 --- /dev/null +++ b/extensions/google/oauth.project.ts @@ -0,0 +1,235 @@ +import { fetchWithTimeout } from "./oauth.http.js"; +import { + CODE_ASSIST_ENDPOINT_PROD, + LOAD_CODE_ASSIST_ENDPOINTS, + TIER_FREE, + TIER_LEGACY, + TIER_STANDARD, + USERINFO_URL, +} from "./oauth.shared.js"; + +function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "darwin") { + return "MACOS"; + } + return "PLATFORM_UNSPECIFIED"; +} + +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetchWithTimeout(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // ignore + } + return undefined; +} + +function isVpcScAffected(payload: unknown): boolean { + if (!payload || typeof payload !== "object") { + return false; + } + const error = (payload as { error?: unknown }).error; + if (!error || typeof error !== "object") { + return false; + } + const details = (error as { details?: unknown[] }).details; + if (!Array.isArray(details)) { + return false; + } + return details.some( + (item) => + typeof item === "object" && + item && + (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", + ); +} + +function getDefaultTier( + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, +): { id?: string } | undefined { + if (!allowedTiers?.length) { + return { id: TIER_LEGACY }; + } + return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; +} + +async function pollOperation( + endpoint: string, + operationName: string, + headers: Record, +): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { + for (let attempt = 0; attempt < 24; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { + headers, + }); + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + done?: boolean; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + if (data.done) { + return data; + } + } + throw new Error("Operation polling timeout"); +} + +export async function resolveGoogleOAuthIdentity(accessToken: string): Promise<{ + email?: string; + projectId: string; +}> { + const email = await getUserEmail(accessToken); + const projectId = await discoverProject(accessToken); + return { email, projectId }; +} + +async function discoverProject(accessToken: string): Promise { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const platform = resolvePlatform(); + const metadata = { + ideType: "ANTIGRAVITY", + platform, + pluginType: "GEMINI", + }; + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": `gl-node/${process.versions.node}`, + "Client-Metadata": JSON.stringify(metadata), + }; + + const loadBody = { + ...(envProject ? { cloudaicompanionProject: envProject } : {}), + metadata: { + ...metadata, + ...(envProject ? { duetProject: envProject } : {}), + }, + }; + + let data: { + currentTier?: { id?: string }; + cloudaicompanionProject?: string | { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + } = {}; + let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; + let loadError: Error | undefined; + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + activeEndpoint = endpoint; + loadError = undefined; + break; + } + loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + continue; + } + + data = (await response.json()) as typeof data; + activeEndpoint = endpoint; + loadError = undefined; + break; + } catch (err) { + loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); + } + } + + const hasLoadCodeAssistData = + Boolean(data.currentTier) || + Boolean(data.cloudaicompanionProject) || + Boolean(data.allowedTiers?.length); + if (!hasLoadCodeAssistData && loadError) { + if (envProject) { + return envProject; + } + throw loadError; + } + + if (data.currentTier) { + const project = data.cloudaicompanionProject; + if (typeof project === "string" && project) { + return project; + } + if (typeof project === "object" && project?.id) { + return project.id; + } + if (envProject) { + return envProject; + } + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const tier = getDefaultTier(data.allowedTiers); + const tierId = tier?.id || TIER_FREE; + if (tierId !== TIER_FREE && !envProject) { + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const onboardBody: Record = { + tierId, + metadata: { + ...metadata, + }, + }; + if (tierId !== TIER_FREE && envProject) { + onboardBody.cloudaicompanionProject = envProject; + (onboardBody.metadata as Record).duetProject = envProject; + } + + const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); + } + + let lro = (await onboardResponse.json()) as { + done?: boolean; + name?: string; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + + if (!lro.done && lro.name) { + lro = await pollOperation(activeEndpoint, lro.name, headers); + } + + const projectId = lro.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + if (envProject) { + return envProject; + } + + throw new Error( + "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", + ); +} diff --git a/extensions/google/oauth.shared.ts b/extensions/google/oauth.shared.ts new file mode 100644 index 00000000000..2b8186737a2 --- /dev/null +++ b/extensions/google/oauth.shared.ts @@ -0,0 +1,44 @@ +export const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; +export const CLIENT_SECRET_KEYS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; +export const REDIRECT_URI = "http://localhost:8085/oauth2callback"; +export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +export const TOKEN_URL = "https://oauth2.googleapis.com/token"; +export const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; +export const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; +export const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +export const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; +export const LOAD_CODE_ASSIST_ENDPOINTS = [ + CODE_ASSIST_ENDPOINT_PROD, + CODE_ASSIST_ENDPOINT_DAILY, + CODE_ASSIST_ENDPOINT_AUTOPUSH, +]; +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +export const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; + +export const TIER_FREE = "free-tier"; +export const TIER_LEGACY = "legacy-tier"; +export const TIER_STANDARD = "standard-tier"; + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId: string; +}; + +export type GeminiCliOAuthContext = { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}; diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts new file mode 100644 index 00000000000..6e2b68c4403 --- /dev/null +++ b/extensions/google/oauth.token.ts @@ -0,0 +1,57 @@ +import { resolveOAuthClientConfig } from "./oauth.credentials.js"; +import { fetchWithTimeout } from "./oauth.http.js"; +import { resolveGoogleOAuthIdentity } from "./oauth.project.js"; +import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js"; + +export async function exchangeCodeForTokens( + code: string, + verifier: string, +): Promise { + const { clientId, clientSecret } = resolveOAuthClientConfig(); + const body = new URLSearchParams({ + client_id: clientId, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + const response = await fetchWithTimeout(TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "User-Agent": "google-api-nodejs-client/9.15.1", + }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error("No refresh token received. Please try again."); + } + + const identity = await resolveGoogleOAuthIdentity(data.access_token); + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId: identity.projectId, + email: identity.email, + }; +} diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts index 5932b3a237b..be12c64a4e1 100644 --- a/extensions/google/oauth.ts +++ b/extensions/google/oauth.ts @@ -1,661 +1,16 @@ -import { createHash, randomBytes } from "node:crypto"; -import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { createServer } from "node:http"; -import { delimiter, dirname, join } from "node:path"; -import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; -import { isWSL2Sync } from "../../src/infra/wsl.js"; - -const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; -const CLIENT_SECRET_KEYS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -]; -const REDIRECT_URI = "http://localhost:8085/oauth2callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; -const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; -const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; -const LOAD_CODE_ASSIST_ENDPOINTS = [ - CODE_ASSIST_ENDPOINT_PROD, - CODE_ASSIST_ENDPOINT_DAILY, - CODE_ASSIST_ENDPOINT_AUTOPUSH, -]; -const DEFAULT_FETCH_TIMEOUT_MS = 10_000; -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; - -const TIER_FREE = "free-tier"; -const TIER_LEGACY = "legacy-tier"; -const TIER_STANDARD = "standard-tier"; - -export type GeminiCliOAuthCredentials = { - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}; - -export type GeminiCliOAuthContext = { - isRemote: boolean; - openUrl: (url: string) => Promise; - log: (msg: string) => void; - note: (message: string, title?: string) => Promise; - prompt: (message: string) => Promise; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}; - -function resolveEnv(keys: string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; - -/** @internal */ -export function clearCredentialsCache(): void { - cachedGeminiCliCredentials = null; -} - -/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ -export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { - if (cachedGeminiCliCredentials) { - return cachedGeminiCliCredentials; - } - - try { - const geminiPath = findInPath("gemini"); - if (!geminiPath) { - return null; - } - - const resolvedPath = realpathSync(geminiPath); - const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); - - let content: string | null = null; - for (const geminiCliDir of geminiCliDirs) { - const searchPaths = [ - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ), - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "code_assist", - "oauth2.js", - ), - ]; - - for (const p of searchPaths) { - if (existsSync(p)) { - content = readFileSync(p, "utf8"); - break; - } - } - if (content) { - break; - } - const found = findFile(geminiCliDir, "oauth2.js", 10); - if (found) { - content = readFileSync(found, "utf8"); - break; - } - } - if (!content) { - return null; - } - - const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); - const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); - if (idMatch && secretMatch) { - cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; - return cachedGeminiCliCredentials; - } - } catch { - // Gemini CLI not installed or extraction failed - } - return null; -} - -function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { - const binDir = dirname(geminiPath); - const candidates = [ - dirname(dirname(resolvedPath)), - join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), - join(binDir, "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), - ]; - - const deduped: string[] = []; - const seen = new Set(); - for (const candidate of candidates) { - const key = - process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(candidate); - } - return deduped; -} - -function findInPath(name: string): string | null { - const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; - for (const dir of (process.env.PATH ?? "").split(delimiter)) { - for (const ext of exts) { - const p = join(dir, name + ext); - if (existsSync(p)) { - return p; - } - } - } - return null; -} - -function findFile(dir: string, name: string, depth: number): string | null { - if (depth <= 0) { - return null; - } - try { - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isFile() && e.name === name) { - return p; - } - if (e.isDirectory() && !e.name.startsWith(".")) { - const found = findFile(p, name, depth - 1); - if (found) { - return found; - } - } - } - } catch {} - return null; -} - -function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - // 1. Check env vars first (user override) - const envClientId = resolveEnv(CLIENT_ID_KEYS); - const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); - if (envClientId) { - return { clientId: envClientId, clientSecret: envClientSecret }; - } - - // 2. Try to extract from installed Gemini CLI - const extracted = extractGeminiCliCredentials(); - if (extracted) { - return extracted; - } - - // 3. No credentials available - throw new Error( - "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", - ); -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" { - if (process.platform === "win32") { - return "WINDOWS"; - } - if (process.platform === "darwin") { - return "MACOS"; - } - // Google's loadCodeAssist API rejects "LINUX" as an invalid Platform enum value. - // Use "PLATFORM_UNSPECIFIED" for Linux and other platforms to match the pi-ai runtime. - return "PLATFORM_UNSPECIFIED"; -} - -async function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, -): Promise { - const { response, release } = await fetchWithSsrFGuard({ - url, - init, - timeoutMs, - }); - try { - const body = await response.arrayBuffer(); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } finally { - await release(); - } -} - -function buildAuthUrl(challenge: string, verifier: string): string { - const { clientId } = resolveOAuthClientConfig(); - const params = new URLSearchParams({ - client_id: clientId, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES.join(" "), - code_challenge: challenge, - code_challenge_method: "S256", - state: verifier, - access_type: "offline", - prompt: "consent", - }); - return `${AUTH_URL}?${params.toString()}`; -} - -function parseCallbackInput( - input: string, - expectedState: string, -): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? expectedState; - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter. Paste the full URL." }; - } - return { code, state }; - } catch { - if (!expectedState) { - return { error: "Paste the full redirect URL, not just the code." }; - } - return { code: trimmed, state: expectedState }; - } -} - -async function waitForLocalCallback(params: { - expectedState: string; - timeoutMs: number; - onProgress?: (message: string) => void; -}): Promise<{ code: string; state: string }> { - const port = 8085; - const hostname = "localhost"; - const expectedPath = "/oauth2callback"; - - return new Promise<{ code: string; state: string }>((resolve, reject) => { - let timeout: NodeJS.Timeout | null = null; - const server = createServer((req, res) => { - try { - const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); - if (requestUrl.pathname !== expectedPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain"); - res.end("Not found"); - return; - } - - const error = requestUrl.searchParams.get("error"); - const code = requestUrl.searchParams.get("code")?.trim(); - const state = requestUrl.searchParams.get("state")?.trim(); - - if (error) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end(`Authentication failed: ${error}`); - finish(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code || !state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end("Missing code or state"); - finish(new Error("Missing OAuth code or state")); - return; - } - - if (state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end("Invalid state"); - finish(new Error("OAuth state mismatch")); - return; - } - - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end( - "" + - "

Gemini CLI OAuth complete

" + - "

You can close this window and return to OpenClaw.

", - ); - - finish(undefined, { code, state }); - } catch (err) { - finish(err instanceof Error ? err : new Error("OAuth callback failed")); - } - }); - - const finish = (err?: Error, result?: { code: string; state: string }) => { - if (timeout) { - clearTimeout(timeout); - } - try { - server.close(); - } catch { - // ignore close errors - } - if (err) { - reject(err); - } else if (result) { - resolve(result); - } - }; - - server.once("error", (err) => { - finish(err instanceof Error ? err : new Error("OAuth callback server error")); - }); - - server.listen(port, hostname, () => { - params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); - }); - - timeout = setTimeout(() => { - finish(new Error("OAuth callback timeout")); - }, params.timeoutMs); - }); -} - -async function exchangeCodeForTokens( - code: string, - verifier: string, -): Promise { - const { clientId, clientSecret } = resolveOAuthClientConfig(); - const body = new URLSearchParams({ - client_id: clientId, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }); - if (clientSecret) { - body.set("client_secret", clientSecret); - } - - const response = await fetchWithTimeout(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "*/*", - "User-Agent": "google-api-nodejs-client/9.15.1", - }, - body, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token exchange failed: ${errorText}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - if (!data.refresh_token) { - throw new Error("No refresh token received. Please try again."); - } - - const email = await getUserEmail(data.access_token); - const projectId = await discoverProject(data.access_token); - const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: data.refresh_token, - access: data.access_token, - expires: expiresAt, - projectId, - email, - }; -} - -async function getUserEmail(accessToken: string): Promise { - try { - const response = await fetchWithTimeout(USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (response.ok) { - const data = (await response.json()) as { email?: string }; - return data.email; - } - } catch { - // ignore - } - return undefined; -} - -async function discoverProject(accessToken: string): Promise { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; - const platform = resolvePlatform(); - const metadata = { - ideType: "ANTIGRAVITY", - platform, - pluginType: "GEMINI", - }; - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": `gl-node/${process.versions.node}`, - "Client-Metadata": JSON.stringify(metadata), - }; - - const loadBody = { - ...(envProject ? { cloudaicompanionProject: envProject } : {}), - metadata: { - ...metadata, - ...(envProject ? { duetProject: envProject } : {}), - }, - }; - - let data: { - currentTier?: { id?: string }; - cloudaicompanionProject?: string | { id?: string }; - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; - } = {}; - let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; - let loadError: Error | undefined; - for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - }); - - if (!response.ok) { - const errorPayload = await response.json().catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - activeEndpoint = endpoint; - loadError = undefined; - break; - } - loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); - continue; - } - - data = (await response.json()) as typeof data; - activeEndpoint = endpoint; - loadError = undefined; - break; - } catch (err) { - loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); - } - } - - const hasLoadCodeAssistData = - Boolean(data.currentTier) || - Boolean(data.cloudaicompanionProject) || - Boolean(data.allowedTiers?.length); - if (!hasLoadCodeAssistData && loadError) { - if (envProject) { - return envProject; - } - throw loadError; - } - - if (data.currentTier) { - const project = data.cloudaicompanionProject; - if (typeof project === "string" && project) { - return project; - } - if (typeof project === "object" && project?.id) { - return project.id; - } - if (envProject) { - return envProject; - } - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const tier = getDefaultTier(data.allowedTiers); - const tierId = tier?.id || TIER_FREE; - if (tierId !== TIER_FREE && !envProject) { - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const onboardBody: Record = { - tierId, - metadata: { - ...metadata, - }, - }; - if (tierId !== TIER_FREE && envProject) { - onboardBody.cloudaicompanionProject = envProject; - (onboardBody.metadata as Record).duetProject = envProject; - } - - const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - }); - - if (!onboardResponse.ok) { - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); - } - - let lro = (await onboardResponse.json()) as { - done?: boolean; - name?: string; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - - if (!lro.done && lro.name) { - lro = await pollOperation(activeEndpoint, lro.name, headers); - } - - const projectId = lro.response?.cloudaicompanionProject?.id; - if (projectId) { - return projectId; - } - if (envProject) { - return envProject; - } - - throw new Error( - "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", - ); -} - -function isVpcScAffected(payload: unknown): boolean { - if (!payload || typeof payload !== "object") { - return false; - } - const error = (payload as { error?: unknown }).error; - if (!error || typeof error !== "object") { - return false; - } - const details = (error as { details?: unknown[] }).details; - if (!Array.isArray(details)) { - return false; - } - return details.some( - (item) => - typeof item === "object" && - item && - (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", - ); -} - -function getDefaultTier( - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, -): { id?: string } | undefined { - if (!allowedTiers?.length) { - return { id: TIER_LEGACY }; - } - return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; -} - -async function pollOperation( - endpoint: string, - operationName: string, - headers: Record, -): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { - for (let attempt = 0; attempt < 24; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 5000)); - const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { - headers, - }); - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - done?: boolean; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - if (data.done) { - return data; - } - } - throw new Error("Operation polling timeout"); -} +import { clearCredentialsCache, extractGeminiCliCredentials } from "./oauth.credentials.js"; +import { + buildAuthUrl, + generatePkce, + parseCallbackInput, + shouldUseManualOAuthFlow, + waitForLocalCallback, +} from "./oauth.flow.js"; +import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js"; +import { exchangeCodeForTokens } from "./oauth.token.js"; + +export { clearCredentialsCache, extractGeminiCliCredentials }; +export type { GeminiCliOAuthContext, GeminiCliOAuthCredentials }; export async function loginGeminiCliOAuth( ctx: GeminiCliOAuthContext, From 59940cb3ee30f1f3c6d5a81a7d1decb694b13c50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:32:47 +0000 Subject: [PATCH 036/331] refactor(plugin-sdk): centralize entrypoint manifest --- package.json | 1 + scripts/check-plugin-sdk-exports.mjs | 48 +-------------- scripts/lib/plugin-sdk-entries.mjs | 78 ++++++++++++++++++++++++ scripts/release-check.ts | 88 +-------------------------- scripts/sync-plugin-sdk-exports.mjs | 34 +++++++++++ scripts/write-plugin-sdk-entry-dts.ts | 48 +-------------- src/plugin-sdk/index.test.ts | 83 ++++++------------------- src/plugin-sdk/subpaths.test.ts | 42 +++---------- tsconfig.plugin-sdk.dts.json | 47 +------------- tsdown.config.ts | 49 +-------------- vitest.config.ts | 46 +------------- 11 files changed, 150 insertions(+), 414 deletions(-) create mode 100644 scripts/lib/plugin-sdk-entries.mjs create mode 100644 scripts/sync-plugin-sdk-exports.mjs diff --git a/package.json b/package.json index 86822b23bf1..cc6925725fa 100644 --- a/package.json +++ b/package.json @@ -292,6 +292,7 @@ "moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", + "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "prepack": "pnpm build && pnpm ui:build", "prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 93fc3fcb545..60c89056ca0 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -11,6 +11,7 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { pluginSdkSubpaths } from "./lib/plugin-sdk-entries.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const distFile = resolve(__dirname, "..", "dist", "plugin-sdk", "index.js"); @@ -41,51 +42,6 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); -const requiredSubpathEntries = [ - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; - const requiredRuntimeShimEntries = ["root-alias.cjs"]; // Critical functions that channel extension plugins import from openclaw/plugin-sdk. @@ -123,7 +79,7 @@ for (const name of requiredExports) { } } -for (const entry of requiredSubpathEntries) { +for (const entry of pluginSdkSubpaths) { const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`); const dtsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.d.ts`); if (!existsSync(jsPath)) { diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs new file mode 100644 index 00000000000..ba6c1a5c386 --- /dev/null +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -0,0 +1,78 @@ +export const pluginSdkEntrypoints = [ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue", +]; + +export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); + +export function buildPluginSdkEntrySources() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ); +} + +export function buildPluginSdkSpecifiers() { + return pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, + ); +} + +export function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + types: `./dist/plugin-sdk/${entry}.d.ts`, + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + +export function listPluginSdkDistArtifacts() { + return pluginSdkEntrypoints.flatMap((entry) => [ + `dist/plugin-sdk/${entry}.js`, + `dist/plugin-sdk/${entry}.d.ts`, + ]); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index b8e4fa6706b..7eedc970103 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -10,6 +10,7 @@ import { type BundledExtension, type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; +import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; @@ -20,93 +21,8 @@ type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], - "dist/plugin-sdk/index.js", - "dist/plugin-sdk/index.d.ts", - "dist/plugin-sdk/core.js", - "dist/plugin-sdk/core.d.ts", + ...listPluginSdkDistArtifacts(), "dist/plugin-sdk/root-alias.cjs", - "dist/plugin-sdk/compat.js", - "dist/plugin-sdk/compat.d.ts", - "dist/plugin-sdk/telegram.js", - "dist/plugin-sdk/telegram.d.ts", - "dist/plugin-sdk/discord.js", - "dist/plugin-sdk/discord.d.ts", - "dist/plugin-sdk/slack.js", - "dist/plugin-sdk/slack.d.ts", - "dist/plugin-sdk/signal.js", - "dist/plugin-sdk/signal.d.ts", - "dist/plugin-sdk/imessage.js", - "dist/plugin-sdk/imessage.d.ts", - "dist/plugin-sdk/whatsapp.js", - "dist/plugin-sdk/whatsapp.d.ts", - "dist/plugin-sdk/line.js", - "dist/plugin-sdk/line.d.ts", - "dist/plugin-sdk/msteams.js", - "dist/plugin-sdk/msteams.d.ts", - "dist/plugin-sdk/acpx.js", - "dist/plugin-sdk/acpx.d.ts", - "dist/plugin-sdk/bluebubbles.js", - "dist/plugin-sdk/bluebubbles.d.ts", - "dist/plugin-sdk/copilot-proxy.js", - "dist/plugin-sdk/copilot-proxy.d.ts", - "dist/plugin-sdk/device-pair.js", - "dist/plugin-sdk/device-pair.d.ts", - "dist/plugin-sdk/diagnostics-otel.js", - "dist/plugin-sdk/diagnostics-otel.d.ts", - "dist/plugin-sdk/diffs.js", - "dist/plugin-sdk/diffs.d.ts", - "dist/plugin-sdk/feishu.js", - "dist/plugin-sdk/feishu.d.ts", - "dist/plugin-sdk/googlechat.js", - "dist/plugin-sdk/googlechat.d.ts", - "dist/plugin-sdk/irc.js", - "dist/plugin-sdk/irc.d.ts", - "dist/plugin-sdk/llm-task.js", - "dist/plugin-sdk/llm-task.d.ts", - "dist/plugin-sdk/lobster.js", - "dist/plugin-sdk/lobster.d.ts", - "dist/plugin-sdk/matrix.js", - "dist/plugin-sdk/matrix.d.ts", - "dist/plugin-sdk/mattermost.js", - "dist/plugin-sdk/mattermost.d.ts", - "dist/plugin-sdk/memory-core.js", - "dist/plugin-sdk/memory-core.d.ts", - "dist/plugin-sdk/memory-lancedb.js", - "dist/plugin-sdk/memory-lancedb.d.ts", - "dist/plugin-sdk/minimax-portal-auth.js", - "dist/plugin-sdk/minimax-portal-auth.d.ts", - "dist/plugin-sdk/nextcloud-talk.js", - "dist/plugin-sdk/nextcloud-talk.d.ts", - "dist/plugin-sdk/nostr.js", - "dist/plugin-sdk/nostr.d.ts", - "dist/plugin-sdk/open-prose.js", - "dist/plugin-sdk/open-prose.d.ts", - "dist/plugin-sdk/phone-control.js", - "dist/plugin-sdk/phone-control.d.ts", - "dist/plugin-sdk/qwen-portal-auth.js", - "dist/plugin-sdk/qwen-portal-auth.d.ts", - "dist/plugin-sdk/synology-chat.js", - "dist/plugin-sdk/synology-chat.d.ts", - "dist/plugin-sdk/talk-voice.js", - "dist/plugin-sdk/talk-voice.d.ts", - "dist/plugin-sdk/test-utils.js", - "dist/plugin-sdk/test-utils.d.ts", - "dist/plugin-sdk/thread-ownership.js", - "dist/plugin-sdk/thread-ownership.d.ts", - "dist/plugin-sdk/tlon.js", - "dist/plugin-sdk/tlon.d.ts", - "dist/plugin-sdk/twitch.js", - "dist/plugin-sdk/twitch.d.ts", - "dist/plugin-sdk/voice-call.js", - "dist/plugin-sdk/voice-call.d.ts", - "dist/plugin-sdk/zalo.js", - "dist/plugin-sdk/zalo.d.ts", - "dist/plugin-sdk/zalouser.js", - "dist/plugin-sdk/zalouser.d.ts", - "dist/plugin-sdk/account-id.js", - "dist/plugin-sdk/account-id.d.ts", - "dist/plugin-sdk/keyed-async-queue.js", - "dist/plugin-sdk/keyed-async-queue.d.ts", "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; diff --git a/scripts/sync-plugin-sdk-exports.mjs b/scripts/sync-plugin-sdk-exports.mjs new file mode 100644 index 00000000000..cfe2e181259 --- /dev/null +++ b/scripts/sync-plugin-sdk-exports.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { buildPluginSdkPackageExports } from "./lib/plugin-sdk-entries.mjs"; + +const packageJsonPath = path.join(process.cwd(), "package.json"); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); +const currentExports = packageJson.exports ?? {}; +const syncedPluginSdkExports = buildPluginSdkPackageExports(); + +const nextExports = {}; +let insertedPluginSdkExports = false; +for (const [key, value] of Object.entries(currentExports)) { + if (key.startsWith("./plugin-sdk")) { + if (!insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); + insertedPluginSdkExports = true; + } + continue; + } + nextExports[key] = value; + if (key === "." && !insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); + insertedPluginSdkExports = true; + } +} + +if (!insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); +} + +packageJson.exports = nextExports; +fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index d0331377432..832368bbcd3 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -1,57 +1,13 @@ import fs from "node:fs"; import path from "node:path"; +import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; // `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives // at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs). // // Our package export map points subpath `types` at `dist/plugin-sdk/.d.ts`, so we // generate stable entry d.ts files that re-export the real declarations. -const entrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; -for (const entry of entrypoints) { +for (const entry of pluginSdkEntrypoints) { const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(out), { recursive: true }); // NodeNext: reference the runtime specifier with `.js`, TS will map it to `.d.ts`. diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 8fe13972e11..4e9a8869849 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -4,68 +4,15 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { build } from "tsdown"; import { describe, expect, it } from "vitest"; +import { + buildPluginSdkEntrySources, + buildPluginSdkPackageExports, + buildPluginSdkSpecifiers, + pluginSdkEntrypoints, +} from "../../scripts/lib/plugin-sdk-entries.mjs"; import * as sdk from "./index.js"; -const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; - -const pluginSdkSpecifiers = pluginSdkEntrypoints.map((entry) => - entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, -); - -function buildPluginSdkPackageExports() { - return Object.fromEntries( - pluginSdkEntrypoints.map((entry) => [ - entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, - { - default: `./dist/plugin-sdk/${entry}.js`, - }, - ]), - ); -} +const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { @@ -180,9 +127,7 @@ describe("plugin-sdk exports", () => { clean: true, config: false, dts: false, - entry: Object.fromEntries( - pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), - ), + entry: buildPluginSdkEntrySources(), env: { NODE_ENV: "production" }, fixedExtension: false, logLevel: "error", @@ -237,4 +182,16 @@ describe("plugin-sdk exports", () => { await fs.rm(fixtureDir, { recursive: true, force: true }); } }); + + it("keeps package.json plugin-sdk exports synced with the manifest", async () => { + const packageJsonPath = path.join(process.cwd(), "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { + exports?: Record; + }; + const currentPluginSdkExports = Object.fromEntries( + Object.entries(packageJson.exports ?? {}).filter(([key]) => key.startsWith("./plugin-sdk")), + ); + + expect(currentPluginSdkExports).toEqual(buildPluginSdkPackageExports()); + }); }); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 42d69512925..6b696be7269 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -9,42 +9,14 @@ import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; +import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; -const bundledExtensionSubpathLoaders = [ - { id: "acpx", load: () => import("openclaw/plugin-sdk/acpx") }, - { id: "bluebubbles", load: () => import("openclaw/plugin-sdk/bluebubbles") }, - { id: "copilot-proxy", load: () => import("openclaw/plugin-sdk/copilot-proxy") }, - { id: "device-pair", load: () => import("openclaw/plugin-sdk/device-pair") }, - { id: "diagnostics-otel", load: () => import("openclaw/plugin-sdk/diagnostics-otel") }, - { id: "diffs", load: () => import("openclaw/plugin-sdk/diffs") }, - { id: "feishu", load: () => import("openclaw/plugin-sdk/feishu") }, - { id: "googlechat", load: () => import("openclaw/plugin-sdk/googlechat") }, - { id: "irc", load: () => import("openclaw/plugin-sdk/irc") }, - { id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") }, - { id: "lobster", load: () => import("openclaw/plugin-sdk/lobster") }, - { id: "matrix", load: () => import("openclaw/plugin-sdk/matrix") }, - { id: "mattermost", load: () => import("openclaw/plugin-sdk/mattermost") }, - { id: "memory-core", load: () => import("openclaw/plugin-sdk/memory-core") }, - { id: "memory-lancedb", load: () => import("openclaw/plugin-sdk/memory-lancedb") }, - { - id: "minimax-portal-auth", - load: () => import("openclaw/plugin-sdk/minimax-portal-auth"), - }, - { id: "nextcloud-talk", load: () => import("openclaw/plugin-sdk/nextcloud-talk") }, - { id: "nostr", load: () => import("openclaw/plugin-sdk/nostr") }, - { id: "open-prose", load: () => import("openclaw/plugin-sdk/open-prose") }, - { id: "phone-control", load: () => import("openclaw/plugin-sdk/phone-control") }, - { id: "qwen-portal-auth", load: () => import("openclaw/plugin-sdk/qwen-portal-auth") }, - { id: "synology-chat", load: () => import("openclaw/plugin-sdk/synology-chat") }, - { id: "talk-voice", load: () => import("openclaw/plugin-sdk/talk-voice") }, - { id: "test-utils", load: () => import("openclaw/plugin-sdk/test-utils") }, - { id: "thread-ownership", load: () => import("openclaw/plugin-sdk/thread-ownership") }, - { id: "tlon", load: () => import("openclaw/plugin-sdk/tlon") }, - { id: "twitch", load: () => import("openclaw/plugin-sdk/twitch") }, - { id: "voice-call", load: () => import("openclaw/plugin-sdk/voice-call") }, - { id: "zalo", load: () => import("openclaw/plugin-sdk/zalo") }, - { id: "zalouser", load: () => import("openclaw/plugin-sdk/zalouser") }, -] as const; +const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); + +const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id) => ({ + id, + load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), +})); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 15828b8b7ad..b182b3e30e4 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -10,51 +10,6 @@ "rootDir": ".", "tsBuildInfoFile": "dist/plugin-sdk/.tsbuildinfo" }, - "include": [ - "src/plugin-sdk/index.ts", - "src/plugin-sdk/core.ts", - "src/plugin-sdk/compat.ts", - "src/plugin-sdk/telegram.ts", - "src/plugin-sdk/discord.ts", - "src/plugin-sdk/slack.ts", - "src/plugin-sdk/signal.ts", - "src/plugin-sdk/imessage.ts", - "src/plugin-sdk/whatsapp.ts", - "src/plugin-sdk/line.ts", - "src/plugin-sdk/msteams.ts", - "src/plugin-sdk/account-id.ts", - "src/plugin-sdk/keyed-async-queue.ts", - "src/plugin-sdk/acpx.ts", - "src/plugin-sdk/bluebubbles.ts", - "src/plugin-sdk/copilot-proxy.ts", - "src/plugin-sdk/device-pair.ts", - "src/plugin-sdk/diagnostics-otel.ts", - "src/plugin-sdk/diffs.ts", - "src/plugin-sdk/feishu.ts", - "src/plugin-sdk/googlechat.ts", - "src/plugin-sdk/irc.ts", - "src/plugin-sdk/llm-task.ts", - "src/plugin-sdk/lobster.ts", - "src/plugin-sdk/matrix.ts", - "src/plugin-sdk/mattermost.ts", - "src/plugin-sdk/memory-core.ts", - "src/plugin-sdk/memory-lancedb.ts", - "src/plugin-sdk/minimax-portal-auth.ts", - "src/plugin-sdk/nextcloud-talk.ts", - "src/plugin-sdk/nostr.ts", - "src/plugin-sdk/open-prose.ts", - "src/plugin-sdk/phone-control.ts", - "src/plugin-sdk/qwen-portal-auth.ts", - "src/plugin-sdk/synology-chat.ts", - "src/plugin-sdk/talk-voice.ts", - "src/plugin-sdk/test-utils.ts", - "src/plugin-sdk/thread-ownership.ts", - "src/plugin-sdk/tlon.ts", - "src/plugin-sdk/twitch.ts", - "src/plugin-sdk/voice-call.ts", - "src/plugin-sdk/zalo.ts", - "src/plugin-sdk/zalouser.ts", - "src/types/**/*.d.ts" - ], + "include": ["src/plugin-sdk/**/*.ts", "src/types/**/*.d.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index b266f660421..80eaae39a4e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig } from "tsdown"; +import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; const env = { NODE_ENV: "production", @@ -58,52 +59,6 @@ function nodeBuildConfig(config: Record) { }; } -const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; - function listBundledPluginBuildEntries(): Record { const extensionsRoot = path.join(process.cwd(), "extensions"); const entries: Record = {}; @@ -189,7 +144,7 @@ export default defineConfig([ nodeBuildConfig({ // Bundle all plugin-sdk entries in a single build so the bundler can share // common chunks instead of duplicating them per entry (~712MB heap saved). - entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])), + entry: buildPluginSdkEntrySources(), outDir: "dist/plugin-sdk", }), nodeBuildConfig({ diff --git a/vitest.config.ts b/vitest.config.ts index c45f5f45c25..564065be9e3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,57 +2,13 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs"; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isWindows = process.platform === "win32"; const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); const ciWorkers = isWindows ? 2 : 3; -const pluginSdkSubpaths = [ - "account-id", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "keyed-async-queue", -] as const; - export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. From 0a136f1b906b09f725cc6ffb0211ceffcc8b9b05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:42:13 +0000 Subject: [PATCH 037/331] fix(docs): harden i18n prompt failures --- scripts/docs-i18n/translator.go | 97 +++++----------------------- scripts/docs-i18n/translator_test.go | 92 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 81 deletions(-) create mode 100644 scripts/docs-i18n/translator_test.go diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index aac2afc5f80..8f7023c615b 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "strings" @@ -14,6 +13,7 @@ import ( const ( translateMaxAttempts = 3 translateBaseDelay = 15 * time.Second + translatePromptTimeout = 2 * time.Minute ) var errEmptyTranslation = errors.New("empty translation") @@ -145,96 +145,31 @@ func (t *PiTranslator) Close() { } } -type agentEndPayload struct { - Messages []agentMessage `json:"messages"` +type promptRunner interface { + Run(context.Context, string) (pi.RunResult, error) + Stderr() string } -type agentMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - StopReason string `json:"stopReason,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - -type contentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -func runPrompt(ctx context.Context, client *pi.OneShotClient, message string) (string, error) { - events, cancel := client.Subscribe(256) +func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) { + promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) defer cancel() - if err := client.Prompt(ctx, message); err != nil { - return "", err - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - case event, ok := <-events: - if !ok { - return "", errors.New("event stream closed") - } - if event.Type == "agent_end" { - return extractTranslationResult(event.Raw) - } - } + result, err := client.Run(promptCtx, message) + if err != nil { + return "", decoratePromptError(err, client.Stderr()) } + return result.Text, nil } -func extractTranslationResult(raw json.RawMessage) (string, error) { - var payload agentEndPayload - if err := json.Unmarshal(raw, &payload); err != nil { - return "", err +func decoratePromptError(err error, stderr string) error { + if err == nil { + return nil } - for index := len(payload.Messages) - 1; index >= 0; index-- { - message := payload.Messages[index] - if message.Role != "assistant" { - continue - } - if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") { - msg := strings.TrimSpace(message.ErrorMessage) - if msg == "" { - msg = "unknown error" - } - return "", fmt.Errorf("pi error: %s", msg) - } - text, err := extractContentText(message.Content) - if err != nil { - return "", err - } - return text, nil - } - return "", errors.New("assistant message not found") -} - -func extractContentText(content json.RawMessage) (string, error) { - trimmed := strings.TrimSpace(string(content)) + trimmed := strings.TrimSpace(stderr) if trimmed == "" { - return "", nil + return err } - if strings.HasPrefix(trimmed, "\"") { - var text string - if err := json.Unmarshal(content, &text); err != nil { - return "", err - } - return text, nil - } - - var blocks []contentBlock - if err := json.Unmarshal(content, &blocks); err != nil { - return "", err - } - - var parts []string - for _, block := range blocks { - if block.Type == "text" && block.Text != "" { - parts = append(parts, block.Text) - } - } - return strings.Join(parts, ""), nil + return fmt.Errorf("%w (pi stderr: %s)", err, trimmed) } func normalizeThinking(value string) string { diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go new file mode 100644 index 00000000000..a632e44e96e --- /dev/null +++ b/scripts/docs-i18n/translator_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + pi "github.com/joshp123/pi-golang" +) + +type fakePromptRunner struct { + run func(context.Context, string) (pi.RunResult, error) + stderr string +} + +func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) { + return runner.run(ctx, message) +} + +func (runner fakePromptRunner) Stderr() string { + return runner.stderr +} + +func TestRunPromptAddsTimeout(t *testing.T) { + t.Parallel() + + var deadline time.Time + client := fakePromptRunner{ + run: func(ctx context.Context, message string) (pi.RunResult, error) { + var ok bool + deadline, ok = ctx.Deadline() + if !ok { + t.Fatal("expected prompt deadline") + } + if message != "Translate me" { + t.Fatalf("unexpected message %q", message) + } + return pi.RunResult{Text: "translated"}, nil + }, + } + + got, err := runPrompt(context.Background(), client, "Translate me") + if err != nil { + t.Fatalf("runPrompt returned error: %v", err) + } + if got != "translated" { + t.Fatalf("unexpected translation %q", got) + } + + remaining := time.Until(deadline) + if remaining <= time.Minute || remaining > translatePromptTimeout { + t.Fatalf("unexpected timeout window %s", remaining) + } +} + +func TestRunPromptIncludesStderr(t *testing.T) { + t.Parallel() + + rootErr := errors.New("context deadline exceeded") + client := fakePromptRunner{ + run: func(context.Context, string) (pi.RunResult, error) { + return pi.RunResult{}, rootErr + }, + stderr: "boom", + } + + _, err := runPrompt(context.Background(), client, "Translate me") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, rootErr) { + t.Fatalf("expected wrapped root error, got %v", err) + } + if !strings.Contains(err.Error(), "pi stderr: boom") { + t.Fatalf("expected stderr in error, got %v", err) + } +} + +func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { + t.Parallel() + + rootErr := errors.New("plain failure") + got := decoratePromptError(rootErr, " ") + if !errors.Is(got, rootErr) { + t.Fatalf("expected original error, got %v", got) + } + if got.Error() != rootErr.Error() { + t.Fatalf("expected unchanged message, got %v", got) + } +} From 6987a3c8b57e650c4236bd87a5b7b0f3e94aed6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:42:45 +0000 Subject: [PATCH 038/331] docs(i18n): sync zh-CN google plugin references --- docs/zh-CN/concepts/model-providers.md | 18 ++++++++---------- docs/zh-CN/help/faq.md | 6 +++--- docs/zh-CN/tools/plugin.md | 7 +++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/zh-CN/concepts/model-providers.md b/docs/zh-CN/concepts/model-providers.md index e55eb7d0e45..ba345d18743 100644 --- a/docs/zh-CN/concepts/model-providers.md +++ b/docs/zh-CN/concepts/model-providers.md @@ -5,10 +5,10 @@ read_when: summary: 模型提供商概述,包含示例配置和 CLI 流程 title: 模型提供商 x-i18n: - generated_at: "2026-02-03T07:46:28Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: 14f73e5a9f9b7c6f017d59a54633942dba95a3eb50f8848b836cfe0b9f6d7719 + source_hash: 978798c80c5809c162f9807072ab48fdf99bfe0db39b2b3c245ce8b4e5451603 source_path: concepts/model-providers.md workflow: 15 --- @@ -87,15 +87,13 @@ OpenClaw 附带 pi-ai 目录。这些提供商**不需要** `models.providers` - 示例模型:`google/gemini-3-pro-preview` - CLI:`openclaw onboard --auth-choice gemini-api-key` -### Google Vertex、Antigravity 和 Gemini CLI +### Google Vertex 和 Gemini CLI -- 提供商:`google-vertex`、`google-antigravity`、`google-gemini-cli` -- 认证:Vertex 使用 gcloud ADC;Antigravity/Gemini CLI 使用各自的认证流程 -- Antigravity OAuth 作为捆绑插件提供(`google-antigravity-auth`,默认禁用)。 - - 启用:`openclaw plugins enable google-antigravity-auth` - - 登录:`openclaw models auth login --provider google-antigravity --set-default` -- Gemini CLI OAuth 作为捆绑插件提供(`google-gemini-cli-auth`,默认禁用)。 - - 启用:`openclaw plugins enable google-gemini-cli-auth` +- 提供商:`google-vertex`、`google-gemini-cli` +- 认证:Vertex 使用 gcloud ADC;Gemini CLI 使用其 OAuth 流程 +- 注意:OpenClaw 中的 Gemini CLI OAuth 属于非官方集成。一些用户报告称,在第三方客户端中使用后其 Google 账号受到了限制。继续前请先查看 Google 条款,并尽量使用非关键账号。 +- Gemini CLI OAuth 作为捆绑 `google` 插件的一部分提供。 + - 启用:`openclaw plugins enable google` - 登录:`openclaw models auth login --provider google-gemini-cli --set-default` - 注意:你**不需要**将客户端 ID 或密钥粘贴到 `openclaw.json` 中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 diff --git a/docs/zh-CN/help/faq.md b/docs/zh-CN/help/faq.md index 3d9742c2b28..feb6aea4341 100644 --- a/docs/zh-CN/help/faq.md +++ b/docs/zh-CN/help/faq.md @@ -2,10 +2,10 @@ summary: 关于 OpenClaw 安装、配置和使用的常见问题 title: 常见问题 x-i18n: - generated_at: "2026-02-01T21:32:04Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: 5a611f2fda3325b1c7a9ec518616d87c78be41e2bfbe86244ae4f48af3815a26 + source_hash: 6e6a4a63fb73dca24dbe77928b51c6b2e5d51ec883fb36c64e2e40ef027050e9 source_path: help/faq.md workflow: 15 --- @@ -687,7 +687,7 @@ Gemini CLI 使用**插件认证流程**,而不是 `openclaw.json` 中的 clien 步骤: -1. 启用插件:`openclaw plugins enable google-gemini-cli-auth` +1. 启用插件:`openclaw plugins enable google` 2. 登录:`openclaw models auth login --provider google-gemini-cli --set-default` 这会在 Gateway 网关主机上将 OAuth 令牌存储为认证配置文件。详情:[模型提供商](/concepts/model-providers)。 diff --git a/docs/zh-CN/tools/plugin.md b/docs/zh-CN/tools/plugin.md index fde337fc3a4..5ec0b9707ff 100644 --- a/docs/zh-CN/tools/plugin.md +++ b/docs/zh-CN/tools/plugin.md @@ -5,10 +5,10 @@ read_when: summary: OpenClaw 插件/扩展:发现、配置和安全 title: 插件 x-i18n: - generated_at: "2026-02-03T07:55:25Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: b36ca6b90ca03eaae25c00f9b12f2717fcd17ac540ba616ee03b398b234c2308 + source_hash: 3c79de31bf50147bdfa6cfc5ed55185e91bb55a8db986df0596b24d5529c7798 source_path: tools/plugin.md workflow: 15 --- @@ -50,8 +50,7 @@ openclaw plugins install @openclaw/voice-call - [Nostr](/channels/nostr) — `@openclaw/nostr` - [Zalo](/channels/zalo) — `@openclaw/zalo` - [Microsoft Teams](/channels/msteams) — `@openclaw/msteams` -- Google Antigravity OAuth(提供商认证)— 作为 `google-antigravity-auth` 捆绑(默认禁用) -- Gemini CLI OAuth(提供商认证)— 作为 `google-gemini-cli-auth` 捆绑(默认禁用) +- Google 网页搜索 + Gemini CLI OAuth — 作为 `google` 捆绑(网页搜索会自动加载;提供商认证仍需手动启用) - Qwen OAuth(提供商认证)— 作为 `qwen-portal-auth` 捆绑(默认禁用) - Copilot Proxy(提供商认证)— 本地 VS Code Copilot Proxy 桥接;与内置 `github-copilot` 设备登录不同(捆绑,默认禁用) From 39aba198f1530be2392e5e7e8a64606f31c95d83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:09 +0000 Subject: [PATCH 039/331] fix(docs): run i18n through a local rpc client --- scripts/docs-i18n/pi_command.go | 120 +++++++++++ scripts/docs-i18n/pi_rpc_client.go | 302 +++++++++++++++++++++++++++ scripts/docs-i18n/translator.go | 25 +-- scripts/docs-i18n/translator_test.go | 62 +++++- 4 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 scripts/docs-i18n/pi_command.go create mode 100644 scripts/docs-i18n/pi_rpc_client.go diff --git a/scripts/docs-i18n/pi_command.go b/scripts/docs-i18n/pi_command.go new file mode 100644 index 00000000000..c11c9134453 --- /dev/null +++ b/scripts/docs-i18n/pi_command.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE" + envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS" + envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION" + defaultPiPackageVersion = "0.58.3" +) + +type docsPiCommand struct { + Executable string + Args []string +} + +var ( + materializedPiRuntimeMu sync.Mutex + materializedPiRuntimeCommand docsPiCommand + materializedPiRuntimeErr error +) + +func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) { + if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" { + return docsPiCommand{ + Executable: executable, + Args: strings.Fields(os.Getenv(envDocsPiArgs)), + }, nil + } + + piPath, err := exec.LookPath("pi") + if err == nil && !shouldMaterializePiRuntime(piPath) { + return docsPiCommand{Executable: piPath}, nil + } + + return ensureMaterializedPiRuntime(ctx) +} + +func shouldMaterializePiRuntime(piPath string) bool { + realPath, err := filepath.EvalSymlinks(piPath) + if err != nil { + realPath = piPath + } + return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/") +} + +func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) { + materializedPiRuntimeMu.Lock() + defer materializedPiRuntimeMu.Unlock() + + if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" { + return materializedPiRuntimeCommand, nil + } + + runtimeDir, err := getMaterializedPiRuntimeDir() + if err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js") + if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) { + installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + + packageVersion := getMaterializedPiPackageVersion() + install := exec.CommandContext( + installCtx, + "npm", + "install", + "--silent", + "--no-audit", + "--no-fund", + fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion), + ) + install.Dir = runtimeDir + install.Env = os.Environ() + output, err := install.CombinedOutput() + if err != nil { + materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output))) + return docsPiCommand{}, materializedPiRuntimeErr + } + } + + materializedPiRuntimeCommand = docsPiCommand{ + Executable: "node", + Args: []string{cliPath}, + } + materializedPiRuntimeErr = nil + return materializedPiRuntimeCommand, nil +} + +func getMaterializedPiRuntimeDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil +} + +func getMaterializedPiPackageVersion() string { + if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" { + return version + } + return defaultPiPackageVersion +} diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go new file mode 100644 index 00000000000..d995c6a171f --- /dev/null +++ b/scripts/docs-i18n/pi_rpc_client.go @@ -0,0 +1,302 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type docsPiClientOptions struct { + SystemPrompt string + Thinking string +} + +type docsPiClient struct { + process *exec.Cmd + stdin io.WriteCloser + stderr bytes.Buffer + events chan piEvent + promptLock sync.Mutex + closeOnce sync.Once + closed chan struct{} + requestID uint64 +} + +type piEvent struct { + Type string + Raw json.RawMessage +} + +type agentEndPayload struct { + Type string `json:"type,omitempty"` + Messages []agentMessage `json:"messages"` +} + +type rpcResponse struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Command string `json:"command,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type agentMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + StopReason string `json:"stopReason,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) { + command, err := resolveDocsPiCommand(ctx) + if err != nil { + return nil, err + } + + args := append([]string{}, command.Args...) + args = append(args, + "--mode", "rpc", + "--provider", "anthropic", + "--model", modelVersion, + "--thinking", options.Thinking, + "--no-session", + ) + if strings.TrimSpace(options.SystemPrompt) != "" { + args = append(args, "--system-prompt", options.SystemPrompt) + } + + process := exec.Command(command.Executable, args...) + agentDir, err := getDocsPiAgentDir() + if err != nil { + return nil, err + } + process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) + stdin, err := process.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := process.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := process.StderrPipe() + if err != nil { + return nil, err + } + + client := &docsPiClient{ + process: process, + stdin: stdin, + events: make(chan piEvent, 256), + closed: make(chan struct{}), + } + + if err := process.Start(); err != nil { + return nil, err + } + + go client.captureStderr(stderr) + go client.readStdout(stdout) + + return client, nil +} + +func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) { + client.promptLock.Lock() + defer client.promptLock.Unlock() + + command := map[string]string{ + "type": "prompt", + "id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)), + "message": message, + } + payload, err := json.Marshal(command) + if err != nil { + return "", err + } + + if _, err := client.stdin.Write(append(payload, '\n')); err != nil { + return "", err + } + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-client.closed: + return "", errors.New("pi process closed") + case event, ok := <-client.events: + if !ok { + return "", errors.New("pi event stream closed") + } + if event.Type == "response" { + response, err := decodeRpcResponse(event.Raw) + if err != nil { + return "", err + } + if !response.Success { + if strings.TrimSpace(response.Error) == "" { + return "", errors.New("pi prompt failed") + } + return "", errors.New(strings.TrimSpace(response.Error)) + } + continue + } + if event.Type == "agent_end" { + return extractTranslationResult(event.Raw) + } + } + } +} + +func (client *docsPiClient) Stderr() string { + return client.stderr.String() +} + +func (client *docsPiClient) Close() error { + client.closeOnce.Do(func() { + close(client.closed) + if client.stdin != nil { + _ = client.stdin.Close() + } + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Signal(syscall.SIGTERM) + } + + done := make(chan struct{}) + go func() { + if client.process != nil { + _ = client.process.Wait() + } + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Kill() + } + } + }) + return nil +} + +func (client *docsPiClient) captureStderr(stderr io.Reader) { + _, _ = io.Copy(&client.stderr, stderr) +} + +func (client *docsPiClient) readStdout(stdout io.Reader) { + defer close(client.events) + + reader := bufio.NewReader(stdout) + for { + line, err := reader.ReadBytes('\n') + line = bytes.TrimSpace(line) + if len(line) > 0 { + var envelope struct { + Type string `json:"type"` + } + if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" { + select { + case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}: + case <-client.closed: + return + } + } + } + if err != nil { + return + } + } +} + +func extractTranslationResult(raw json.RawMessage) (string, error) { + var payload agentEndPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return "", err + } + for index := len(payload.Messages) - 1; index >= 0; index-- { + message := payload.Messages[index] + if message.Role != "assistant" { + continue + } + if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") { + msg := strings.TrimSpace(message.ErrorMessage) + if msg == "" { + msg = "unknown error" + } + return "", fmt.Errorf("pi error: %s", msg) + } + text, err := extractContentText(message.Content) + if err != nil { + return "", err + } + return text, nil + } + return "", errors.New("assistant message not found") +} + +func extractContentText(content json.RawMessage) (string, error) { + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "\"") { + var text string + if err := json.Unmarshal(content, &text); err != nil { + return "", err + } + return text, nil + } + + var blocks []contentBlock + if err := json.Unmarshal(content, &blocks); err != nil { + return "", err + } + + var parts []string + for _, block := range blocks { + if block.Type == "text" && block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, ""), nil +} + +func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) { + var response rpcResponse + if err := json.Unmarshal(raw, &response); err != nil { + return rpcResponse{}, err + } + return response, nil +} + +func getDocsPiAgentDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 8f7023c615b..122a30ec5d5 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -6,8 +6,6 @@ import ( "fmt" "strings" "time" - - pi "github.com/joshp123/pi-golang" ) const ( @@ -19,21 +17,14 @@ const ( var errEmptyTranslation = errors.New("empty translation") type PiTranslator struct { - client *pi.OneShotClient + client *docsPiClient } func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) { - options := pi.DefaultOneShotOptions() - options.AppName = "openclaw-docs-i18n" - options.WorkDir = "/tmp" - options.Mode = pi.ModeDragons - options.Dragons = pi.DragonsOptions{ - Provider: "anthropic", - Model: modelVersion, - Thinking: normalizeThinking(thinking), - } - options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary) - client, err := pi.StartOneShot(options) + client, err := startDocsPiClient(context.Background(), docsPiClientOptions{ + SystemPrompt: translationPrompt(srcLang, tgtLang, glossary), + Thinking: normalizeThinking(thinking), + }) if err != nil { return nil, err } @@ -146,7 +137,7 @@ func (t *PiTranslator) Close() { } type promptRunner interface { - Run(context.Context, string) (pi.RunResult, error) + Prompt(context.Context, string) (string, error) Stderr() string } @@ -154,11 +145,11 @@ func runPrompt(ctx context.Context, client promptRunner, message string) (string promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) defer cancel() - result, err := client.Run(promptCtx, message) + result, err := client.Prompt(promptCtx, message) if err != nil { return "", decoratePromptError(err, client.Stderr()) } - return result.Text, nil + return result, nil } func decoratePromptError(err error, stderr string) error { diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index a632e44e96e..3872d6dff07 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -3,20 +3,20 @@ package main import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" - - pi "github.com/joshp123/pi-golang" ) type fakePromptRunner struct { - run func(context.Context, string) (pi.RunResult, error) + prompt func(context.Context, string) (string, error) stderr string } -func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) { - return runner.run(ctx, message) +func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) { + return runner.prompt(ctx, message) } func (runner fakePromptRunner) Stderr() string { @@ -28,7 +28,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { var deadline time.Time client := fakePromptRunner{ - run: func(ctx context.Context, message string) (pi.RunResult, error) { + prompt: func(ctx context.Context, message string) (string, error) { var ok bool deadline, ok = ctx.Deadline() if !ok { @@ -37,7 +37,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { if message != "Translate me" { t.Fatalf("unexpected message %q", message) } - return pi.RunResult{Text: "translated"}, nil + return "translated", nil }, } @@ -60,8 +60,8 @@ func TestRunPromptIncludesStderr(t *testing.T) { rootErr := errors.New("context deadline exceeded") client := fakePromptRunner{ - run: func(context.Context, string) (pi.RunResult, error) { - return pi.RunResult{}, rootErr + prompt: func(context.Context, string) (string, error) { + return "", rootErr }, stderr: "boom", } @@ -90,3 +90,47 @@ func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { t.Fatalf("expected unchanged message, got %v", got) } } + +func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) { + t.Setenv(envDocsPiExecutable, "/tmp/custom-pi") + t.Setenv(envDocsPiArgs, "--mode rpc --foo bar") + + command, err := resolveDocsPiCommand(context.Background()) + if err != nil { + t.Fatalf("resolveDocsPiCommand returned error: %v", err) + } + + if command.Executable != "/tmp/custom-pi" { + t.Fatalf("unexpected executable %q", command.Executable) + } + if strings.Join(command.Args, " ") != "--mode rpc --foo bar" { + t.Fatalf("unexpected args %v", command.Args) + } +} + +func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist") + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + target := filepath.Join(sourceDir, "cli.js") + if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(binDir, "pi") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + if !shouldMaterializePiRuntime(link) { + t.Fatal("expected pi-mono wrapper to materialize runtime") + } +} From 2b57d3bb34bb0dca2396b8b612af59c60b1ad9d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:15 +0000 Subject: [PATCH 040/331] build(plugin-sdk): enforce export sync in check --- package.json | 3 ++- scripts/sync-plugin-sdk-exports.mjs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cc6925725fa..49fa28e6b2d 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -292,6 +292,7 @@ "moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", + "plugin-sdk:check-exports": "node scripts/sync-plugin-sdk-exports.mjs --check", "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "prepack": "pnpm build && pnpm ui:build", diff --git a/scripts/sync-plugin-sdk-exports.mjs b/scripts/sync-plugin-sdk-exports.mjs index cfe2e181259..b7e0aa29ae5 100644 --- a/scripts/sync-plugin-sdk-exports.mjs +++ b/scripts/sync-plugin-sdk-exports.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildPluginSdkPackageExports } from "./lib/plugin-sdk-entries.mjs"; +const checkOnly = process.argv.includes("--check"); const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const currentExports = packageJson.exports ?? {}; @@ -30,5 +31,16 @@ if (!insertedPluginSdkExports) { Object.assign(nextExports, syncedPluginSdkExports); } +const nextExportsJson = JSON.stringify(nextExports); +const currentExportsJson = JSON.stringify(currentExports); +if (checkOnly) { + if (currentExportsJson !== nextExportsJson) { + console.error("plugin-sdk exports out of sync. Run `pnpm plugin-sdk:sync-exports`."); + process.exit(1); + } + console.log("plugin-sdk exports synced."); + process.exit(0); +} + packageJson.exports = nextExports; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); From 10f4a03de88799316c38e05c9fc1bb364a24d281 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:32 +0000 Subject: [PATCH 041/331] docs(google): remove stale plugin references --- .github/labeler.yml | 8 -------- docs/cli/index.md | 2 +- docs/zh-CN/cli/index.md | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index d980a8d096e..b6422060fea 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -198,14 +198,6 @@ - changed-files: - any-glob-to-any-file: - "extensions/diagnostics-otel/**" -"extensions: google-antigravity-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-antigravity-auth/**" -"extensions: google-gemini-cli-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-gemini-cli-auth/**" "extensions: llm-task": - changed-files: - any-glob-to-any-file: diff --git a/docs/cli/index.md b/docs/cli/index.md index ddedc7ca1aa..fbc0bf1378f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -676,7 +676,7 @@ Surfaces: Notes: - Data comes directly from provider usage endpoints (no estimates). -- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled. +- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI via the bundled `google` plugin and Antigravity where configured. - If no matching credentials exist, usage is hidden. - Details: see [Usage tracking](/concepts/usage-tracking). diff --git a/docs/zh-CN/cli/index.md b/docs/zh-CN/cli/index.md index c22fad5c4b4..e7ae99ef935 100644 --- a/docs/zh-CN/cli/index.md +++ b/docs/zh-CN/cli/index.md @@ -589,7 +589,7 @@ Gmail Pub/Sub 钩子设置 + 运行器。参见 [/automation/gmail-pubsub](/auto 说明: - 数据直接来自提供商用量端点(非估算)。 -- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及启用这些提供商插件时的 Gemini CLI/Antigravity。 +- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及通过捆绑 `google` 插件提供的 Gemini CLI 和已配置的 Antigravity。 - 如果没有匹配的凭证,用量会被隐藏。 - 详情:参见[用量跟踪](/concepts/usage-tracking)。 From 9785b44307fa3f96ccae15e5726fd38a592af1e4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:12:38 -0700 Subject: [PATCH 042/331] IRC: split setup adapter helpers --- extensions/irc/src/channel.ts | 3 +- extensions/irc/src/setup-core.ts | 147 +++++++++++++++++++++++++ extensions/irc/src/setup-surface.ts | 162 ++++------------------------ 3 files changed, 169 insertions(+), 143 deletions(-) create mode 100644 extensions/irc/src/setup-core.ts diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index b1fd0fc89d8..ca53d53a93d 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -36,7 +36,8 @@ import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; -import { ircSetupAdapter, ircSetupWizard } from "./setup-surface.js"; +import { ircSetupAdapter } from "./setup-core.js"; +import { ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; const meta = getChatChannelMeta("irc"); diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts new file mode 100644 index 00000000000..45f9041f973 --- /dev/null +++ b/extensions/irc/src/setup-core.ts @@ -0,0 +1,147 @@ +import { + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; + +const channel = "irc" as const; + +type IrcSetupInput = ChannelSetupInput & { + host?: string; + port?: number | string; + tls?: boolean; + nick?: string; + username?: string; + realname?: string; + channels?: string[]; + password?: string; +}; + +export function parsePort(raw: string, fallback: number): number { + const trimmed = raw.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { + return fallback; + } + return parsed; +} + +export function updateIrcAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: Partial, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }) as CoreConfig; +} + +export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }) as CoreConfig; +} + +export function setIrcNickServ( + cfg: CoreConfig, + accountId: string, + nickserv?: IrcNickServConfig, +): CoreConfig { + return updateIrcAccountConfig(cfg, accountId, { nickserv }); +} + +export function setIrcGroupAccess( + cfg: CoreConfig, + accountId: string, + policy: "open" | "allowlist" | "disabled", + entries: string[], + normalizeGroupEntry: (raw: string) => string | null, +): CoreConfig { + if (policy !== "allowlist") { + return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); + } + const normalizedEntries = [ + ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), + ]; + const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); + return updateIrcAccountConfig(cfg, accountId, { + enabled: true, + groupPolicy: "allowlist", + groups, + }); +} + +export const ircSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + const setupInput = input as IrcSetupInput; + if (!setupInput.host?.trim()) { + return "IRC requires host."; + } + if (!setupInput.nick?.trim()) { + return "IRC requires nick."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as IrcSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const portInput = + typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); + const patch: Partial = { + enabled: true, + host: setupInput.host?.trim(), + port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, + tls: setupInput.tls, + nick: setupInput.nick?.trim(), + username: setupInput.username?.trim(), + realname: setupInput.realname?.trim(), + password: setupInput.password?.trim(), + channels: setupInput.channels, + }; + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch, + }) as CoreConfig; + }, +}; diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index aaee61a9532..63a7bec920b 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -2,18 +2,10 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/on import { resolveOnboardingAccountId, setOnboardingChannelEnabled, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; @@ -22,23 +14,21 @@ import { normalizeIrcAllowEntry, normalizeIrcMessagingTarget, } from "./normalize.js"; +import { + ircSetupAdapter, + parsePort, + setIrcAllowFrom, + setIrcDmPolicy, + setIrcGroupAccess, + setIrcNickServ, + updateIrcAccountConfig, +} from "./setup-core.js"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; const USE_ENV_FLAG = "__ircUseEnv"; const TLS_FLAG = "__ircTls"; -type IrcSetupInput = ChannelSetupInput & { - host?: string; - port?: number | string; - tls?: boolean; - nick?: string; - username?: string; - realname?: string; - channels?: string[]; - password?: string; -}; - function parseListInput(raw: string): string[] { return raw .split(/[\n,;]+/g) @@ -46,18 +36,6 @@ function parseListInput(raw: string): string[] { .filter(Boolean); } -function parsePort(raw: string, fallback: number): number { - const trimmed = raw.trim(); - if (!trimmed) { - return fallback; - } - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { - return fallback; - } - return parsed; -} - function normalizeGroupEntry(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -73,65 +51,6 @@ function normalizeGroupEntry(raw: string): string | null { return `#${normalized.replace(/^#+/, "")}`; } -function updateIrcAccountConfig( - cfg: CoreConfig, - accountId: string, - patch: Partial, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }) as CoreConfig; -} - -function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - -function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as CoreConfig; -} - -function setIrcNickServ( - cfg: CoreConfig, - accountId: string, - nickserv?: IrcNickServConfig, -): CoreConfig { - return updateIrcAccountConfig(cfg, accountId, { nickserv }); -} - -function setIrcGroupAccess( - cfg: CoreConfig, - accountId: string, - policy: "open" | "allowlist" | "disabled", - entries: string[], -): CoreConfig { - if (policy !== "allowlist") { - return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); - } - const normalizedEntries = [ - ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), - ]; - const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); - return updateIrcAccountConfig(cfg, accountId, { - enabled: true, - groupPolicy: "allowlist", - groups, - }); -} - async function promptIrcAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -264,55 +183,6 @@ const ircDmPolicy: ChannelOnboardingDmPolicy = { }), }; -export const ircSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - const setupInput = input as IrcSetupInput; - if (!setupInput.host?.trim()) { - return "IRC requires host."; - } - if (!setupInput.nick?.trim()) { - return "IRC requires nick."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as IrcSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: setupInput.name, - }); - const portInput = - typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); - const patch: Partial = { - enabled: true, - host: setupInput.host?.trim(), - port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, - tls: setupInput.tls, - nick: setupInput.nick?.trim(), - username: setupInput.username?.trim(), - realname: setupInput.realname?.trim(), - password: setupInput.password?.trim(), - channels: setupInput.channels, - }; - return patchScopedAccountConfig({ - cfg: namedConfig, - channelKey: channel, - accountId, - patch, - }) as CoreConfig; - }, -}; - export const ircSetupWizard: ChannelSetupWizard = { channel, status: { @@ -509,11 +379,17 @@ export const ircSetupWizard: ChannelSetupWizard = { updatePrompt: ({ cfg, accountId }) => Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups), setPolicy: ({ cfg, accountId, policy }) => - setIrcGroupAccess(cfg as CoreConfig, accountId, policy, []), + setIrcGroupAccess(cfg as CoreConfig, accountId, policy, [], normalizeGroupEntry), resolveAllowlist: async ({ entries }) => [...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[], applyAllowlist: ({ cfg, accountId, resolved }) => - setIrcGroupAccess(cfg as CoreConfig, accountId, "allowlist", resolved as string[]), + setIrcGroupAccess( + cfg as CoreConfig, + accountId, + "allowlist", + resolved as string[], + normalizeGroupEntry, + ), }, allowFrom: { helpTitle: "IRC allowlist", @@ -584,3 +460,5 @@ export const ircSetupWizard: ChannelSetupWizard = { dmPolicy: ircDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { ircSetupAdapter }; From ec93398d7be6c2e7124d6eb9234a972c5395e310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:14:11 -0700 Subject: [PATCH 043/331] refactor: move line to setup wizard --- extensions/line/src/channel.ts | 134 +-------- extensions/line/src/setup-surface.test.ts | 77 +++++ extensions/line/src/setup-surface.ts | 350 ++++++++++++++++++++++ src/plugin-sdk/line.ts | 2 + src/plugin-sdk/subpaths.test.ts | 2 + 5 files changed, 434 insertions(+), 131 deletions(-) create mode 100644 extensions/line/src/setup-surface.test.ts create mode 100644 extensions/line/src/setup-surface.ts diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 982d7670082..4c2b51cd6d0 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -20,6 +20,7 @@ import { type ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { getLineRuntime } from "./runtime.js"; +import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; // LINE channel metadata const meta = { @@ -62,42 +63,6 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); -function patchLineAccountConfig( - cfg: OpenClawConfig, - lineConfig: LineConfig, - accountId: string, - patch: Record, -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - ...patch, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - ...patch, - }, - }, - }, - }, - }; -} - export const linePlugin: ChannelPlugin = { id: "line", meta: { @@ -131,6 +96,7 @@ export const linePlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), + setupWizard: lineSetupWizard, config: { ...lineConfigBase, isConfigured: (account) => @@ -200,101 +166,7 @@ export const linePlugin: ChannelPlugin = { listPeers: async () => [], listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => - getLineRuntime().channel.line.normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => { - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - return patchLineAccountConfig(cfg, lineConfig, accountId, { name }); - }, - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as { - name?: string; - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.useEnv - ? {} - : typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.useEnv - ? {} - : typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }, - }, - }; - }, - }, + setup: lineSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts new file mode 100644 index 00000000000..9fbddc19675 --- /dev/null +++ b/extensions/line/src/setup-surface.test.ts @@ -0,0 +1,77 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/line"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: { + id: "line", + meta: { label: "LINE" }, + config: { + listAccountIds: listLineAccountIds, + defaultAccountId: resolveDefaultLineAccountId, + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, + }, + setup: lineSetupAdapter, + } as Parameters[0]["plugin"], + wizard: lineSetupWizard, +}); + +describe("line setup wizard", () => { + it("configures token and secret for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter LINE channel access token") { + return "line-token"; + } + if (message === "Enter LINE channel secret") { + return "line-secret"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await lineConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.line?.enabled).toBe(true); + expect(result.cfg.channels?.line?.channelAccessToken).toBe("line-token"); + expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret"); + }); +}); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts new file mode 100644 index 00000000000..1b7a22dfb11 --- /dev/null +++ b/extensions/line/src/setup-surface.ts @@ -0,0 +1,350 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + listLineAccountIds, + normalizeAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; + +const channel = "line" as const; + +const LINE_SETUP_HELP_LINES = [ + "1) Open the LINE Developers Console and create or pick a Messaging API channel", + "2) Copy the channel access token and channel secret", + "3) Enable Use webhook in the Messaging API settings", + "4) Point the webhook at https:///line/webhook", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +const LINE_ALLOW_FROM_HELP_LINES = [ + "Allowlist LINE DMs by user id.", + "LINE ids are case-sensitive.", + "Examples:", + "- U1234567890abcdef1234567890abcdef", + "- line:user:U1234567890abcdef1234567890abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +const lineDmPolicy: ChannelOnboardingDmPolicy = { + label: "LINE", + channel, + policyKey: "channels.line.dmPolicy", + allowFromKey: "channels.line.allowFrom", + getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), +}; + +export const lineSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + patchLineAccountConfig({ + cfg, + accountId, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + clearFields: typedInput.useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: typedInput.useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + } + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + patch: { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + }, +}; + +export const lineSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + secret", + configuredHint: "configured", + unconfiguredHint: "needs token + secret", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listLineAccountIds(cfg).some((accountId) => isLineConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `LINE: ${configured ? "configured" : "needs token + secret"}`, + `Accounts: ${listLineAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "LINE Messaging API", + lines: LINE_SETUP_HELP_LINES, + shouldShow: ({ cfg, accountId }) => !isLineConfigured(cfg, accountId), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "channel access token", + preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_ACCESS_TOKEN detected. Use env var?", + keepPrompt: "LINE channel access token already configured. Keep it?", + inputPrompt: "Enter LINE channel access token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelAccessToken?.trim() || resolved.config.tokenFile?.trim(), + ), + resolvedValue: resolved.channelAccessToken.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelAccessToken", "tokenFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["tokenFile"], + patch: { channelAccessToken: resolvedValue }, + }), + }, + { + inputKey: "password", + providerHint: "line-secret", + credentialLabel: "channel secret", + preferredEnvVar: "LINE_CHANNEL_SECRET", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_SECRET detected. Use env var?", + keepPrompt: "LINE channel secret already configured. Keep it?", + inputPrompt: "Enter LINE channel secret", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelSecret?.trim() || resolved.config.secretFile?.trim(), + ), + resolvedValue: resolved.channelSecret.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_SECRET?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelSecret", "secretFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["secretFile"], + patch: { channelSecret: resolvedValue }, + }), + }, + ], + allowFrom: { + helpTitle: "LINE allowlist", + helpLines: LINE_ALLOW_FROM_HELP_LINES, + message: "LINE allowFrom (user id)", + placeholder: "U1234567890abcdef1234567890abcdef", + invalidWithoutCredentialNote: + "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", + parseInputs: splitOnboardingEntries, + parseId: parseLineAllowFromId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseLineAllowFromId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: ({ cfg, accountId, allowFrom }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: lineDmPolicy, + completionNote: { + title: "LINE webhook", + lines: [ + "Enable Use webhook in the LINE console after saving credentials.", + "Default webhook URL: https:///line/webhook", + "If you set channels.line.webhookPath, update the URL to match.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, + ], + }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 0318e5ac1e7..d0c6ffcaf86 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -8,6 +8,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -26,6 +27,7 @@ export { buildTokenChannelStatusSummary, } from "./status-helpers.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6b696be7269..3315cbe5963 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -82,6 +82,8 @@ describe("plugin-sdk subpath exports", () => { it("exports LINE helpers", () => { expect(typeof lineSdk.processLineMessage).toBe("function"); expect(typeof lineSdk.createInfoCard).toBe("function"); + expect(typeof lineSdk.lineSetupWizard).toBe("object"); + expect(typeof lineSdk.lineSetupAdapter).toBe("object"); }); it("exports Microsoft Teams helpers", () => { From 60bf58ddbc7cc174f80c1000d683620962a95789 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:14:21 -0700 Subject: [PATCH 044/331] refactor: trim onboarding sdk exports --- docs/refactor/plugin-sdk.md | 2 +- extensions/mattermost/src/onboarding-helpers.ts | 1 - src/plugin-sdk/bluebubbles.ts | 2 -- src/plugin-sdk/index.ts | 16 +--------------- src/plugin-sdk/mattermost.ts | 3 --- src/plugin-sdk/nextcloud-talk.ts | 2 -- 6 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 extensions/mattermost/src/onboarding-helpers.ts diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 4722644083b..a6a10cf9472 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -28,7 +28,7 @@ Contents (examples): - Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`, `applyAccountNameToChannelSection`. - Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`. -- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types. +- Setup entry points: host-owned `setup` + `setupWizard`; avoid broad public onboarding helpers. - Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`. - Docs link helper: `formatDocsLink`. diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts deleted file mode 100644 index e78abf5ebec..00000000000 --- a/extensions/mattermost/src/onboarding-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { promptAccountId, resolveAccountIdForConfigure } from "openclaw/plugin-sdk/mattermost"; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index dff21c19bd7..4527f24917d 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -34,8 +34,6 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 2880a60ee58..586ab32b8a6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -615,21 +615,6 @@ export { } from "../channels/plugins/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { - addWildcardAllowFrom, - mergeAllowFromEntries, - promptAccountId, - resolveAccountIdForConfigure, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, -} from "../channels/plugins/onboarding/helpers.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; - export { createActionGate, jsonResult, @@ -801,6 +786,7 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineConfig, diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 54cf2a1bd2f..6cfeeacd918 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -28,13 +28,10 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export type { ChannelOnboardingAdapter } from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 7e2434914bb..f0d2e1de29d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -21,10 +21,8 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { From 067215629f7148f0d2aef804d46375e0675820a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:15:32 -0700 Subject: [PATCH 045/331] Telegram: split setup adapter helpers --- extensions/telegram/src/channel.ts | 3 +- extensions/telegram/src/setup-core.ts | 191 ++++++++++++++++++++++ extensions/telegram/src/setup-surface.ts | 197 ++--------------------- src/plugin-sdk/index.ts | 6 +- src/plugin-sdk/telegram.ts | 6 +- 5 files changed, 208 insertions(+), 195 deletions(-) create mode 100644 extensions/telegram/src/setup-core.ts diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 51dc7811764..4b648b667e6 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -42,7 +42,8 @@ import { resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; import { getTelegramRuntime } from "./runtime.js"; -import { telegramSetupAdapter, telegramSetupWizard } from "./setup-surface.js"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts new file mode 100644 index 00000000000..fe9c9993035 --- /dev/null +++ b/extensions/telegram/src/setup-core.ts @@ -0,0 +1,191 @@ +import { + patchChannelConfigForAccount, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; + +const channel = "telegram" as const; + +export const TELEGRAM_TOKEN_HELP_LINES = [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export const TELEGRAM_USER_ID_HELP_LINES = [ + `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, + "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + "3) Third-party: DM @userinfobot or @getidsbot", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export function normalizeTelegramAllowFromInput(raw: string): string { + return raw + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function parseTelegramAllowFromId(raw: string): string | null { + const stripped = normalizeTelegramAllowFromInput(raw); + return /^\d+$/.test(stripped) ? stripped : null; +} + +export async function resolveTelegramAllowFromEntries(params: { + entries: string[]; + credentialValue?: string; +}) { + return await Promise.all( + params.entries.map(async (entry) => { + const numericId = parseTelegramAllowFromId(entry); + if (numericId) { + return { input: entry, resolved: true, id: numericId }; + } + const stripped = normalizeTelegramAllowFromInput(entry); + if (!stripped || !params.credentialValue?.trim()) { + return { input: entry, resolved: false, id: null }; + } + const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const id = await fetchTelegramChatId({ + token: params.credentialValue, + chatId: username, + }); + return { input: entry, resolved: Boolean(id), id }; + }), + ); +} + +export async function promptTelegramAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: Parameters< + NonNullable< + import("../../../src/channels/plugins/onboarding-types.js").ChannelOnboardingDmPolicy["promptAllowFrom"] + > + >[0]["prompter"]; + accountId?: string; +}) { + const accountId = params.accountId ?? resolveDefaultTelegramAccountId(params.cfg); + const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); + await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); + if (!resolved.token?.trim()) { + await params.prompter.note( + "Telegram token missing; username lookup is unavailable.", + "Telegram", + ); + } + const { promptResolvedAllowFrom } = + await import("../../../src/channels/plugins/onboarding/helpers.js"); + const unique = await promptResolvedAllowFrom({ + prompter: params.prompter, + existing: resolved.config.allowFrom ?? [], + token: resolved.token, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + label: "Telegram allowlist", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + invalidWithoutTokenNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + resolveEntries: async ({ entries, token }) => + resolveTelegramAllowFromEntries({ + credentialValue: token, + entries, + }), + }); + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom: unique }, + }); +} + +export const telegramSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Telegram requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + accounts: { + ...next.channels?.telegram?.accounts, + [accountId]: { + ...next.channels?.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, +}; diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index bb46fc963ac..3fcf09ed7db 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,128 +1,27 @@ import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, - promptResolvedAllowFrom, - resolveOnboardingAccountId, setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { inspectTelegramAccount } from "./account-inspect.js"; +import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; + parseTelegramAllowFromId, + promptTelegramAllowFromForAccount, + resolveTelegramAllowFromEntries, + TELEGRAM_TOKEN_HELP_LINES, + TELEGRAM_USER_ID_HELP_LINES, + telegramSetupAdapter, +} from "./setup-core.js"; const channel = "telegram" as const; -const TELEGRAM_TOKEN_HELP_LINES = [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", -]; - -const TELEGRAM_USER_ID_HELP_LINES = [ - `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", - "3) Third-party: DM @userinfobot or @getidsbot", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", -]; - -export function normalizeTelegramAllowFromInput(raw: string): string { - return raw - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function parseTelegramAllowFromId(raw: string): string | null { - const stripped = normalizeTelegramAllowFromInput(raw); - return /^\d+$/.test(stripped) ? stripped : null; -} - -async function resolveTelegramAllowFromEntries(params: { - entries: string[]; - credentialValue?: string; -}) { - return await Promise.all( - params.entries.map(async (entry) => { - const numericId = parseTelegramAllowFromId(entry); - if (numericId) { - return { input: entry, resolved: true, id: numericId }; - } - const stripped = normalizeTelegramAllowFromInput(entry); - if (!stripped || !params.credentialValue?.trim()) { - return { input: entry, resolved: false, id: null }; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ - token: params.credentialValue, - chatId: username, - }); - return { input: entry, resolved: Boolean(id), id }; - }), - ); -} - -async function promptTelegramAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), - }); - const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); - await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); - if (!resolved.token?.trim()) { - await params.prompter.note( - "Telegram token missing; username lookup is unavailable.", - "Telegram", - ); - } - const unique = await promptResolvedAllowFrom({ - prompter: params.prompter, - existing: resolved.config.allowFrom ?? [], - token: resolved.token, - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, - parseId: parseTelegramAllowFromId, - invalidWithoutTokenNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - resolveEntries: async ({ entries, token }) => - resolveTelegramAllowFromEntries({ - credentialValue: token, - entries, - }), - }); - return patchChannelConfigForAccount({ - cfg: params.cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom: unique }, - }); -} - const dmPolicy: ChannelOnboardingDmPolicy = { label: "Telegram", channel, @@ -138,82 +37,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptTelegramAllowFromForAccount, }; -export const telegramSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "TELEGRAM_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Telegram requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, -}; - export const telegramSetupWizard: ChannelSetupWizard = { channel, status: { @@ -284,3 +107,5 @@ export const telegramSetupWizard: ChannelSetupWizard = { dmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 586ab32b8a6..699d0778522 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -743,10 +743,8 @@ export { } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export { - telegramSetupAdapter, - telegramSetupWizard, -} from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 64502bf2703..7504994f70a 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -64,10 +64,8 @@ export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - telegramSetupAdapter, - telegramSetupWizard, -} from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; From c6950367fb8070c05e10d0f6f5f9b96fd54025f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:18:55 +0000 Subject: [PATCH 046/331] fix: allow plugin package id hints --- src/plugins/manifest-registry.test.ts | 17 +++++++++++++++++ src/plugins/manifest-registry.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 6f4c0353330..84e5f13fd98 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -331,6 +331,23 @@ describe("loadPluginManifestRegistry", () => { ); }); + it("accepts plugin-style id hints without warning", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "brave", configSchema: { type: "object" } }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "brave-plugin", + rootDir: dir, + origin: "bundled", + }), + ]); + + expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( + false, + ); + }); + it("still warns for unrelated id hint mismatches", () => { const dir = makeTempDir(); writeManifest(dir, { id: "openai", configSchema: { type: "object" } }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 2c24b87f541..4f43cff8e2b 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -131,7 +131,7 @@ function isCompatiblePluginIdHint(idHint: string | undefined, manifestId: string if (normalizedHint === manifestId) { return true; } - return normalizedHint === `${manifestId}-provider`; + return normalizedHint === `${manifestId}-provider` || normalizedHint === `${manifestId}-plugin`; } function buildRecord(params: { From c89527f38950a8bceddd0c6f779dbacfe5251ae0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:19:22 -0700 Subject: [PATCH 047/331] Tlon: split setup adapter helpers --- extensions/tlon/src/channel.ts | 3 +- extensions/tlon/src/setup-core.ts | 101 +++++++++++++++++++++++++++ extensions/tlon/src/setup-surface.ts | 100 +------------------------- src/plugin-sdk/tlon.ts | 3 +- 4 files changed, 108 insertions(+), 99 deletions(-) create mode 100644 extensions/tlon/src/setup-core.ts diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 7a460a6adb8..9282fcf92f9 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -7,7 +7,8 @@ import type { } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { monitorTlonProvider } from "./monitor/index.js"; -import { tlonSetupAdapter, tlonSetupWizard } from "./setup-surface.js"; +import { tlonSetupAdapter } from "./setup-core.js"; +import { tlonSetupWizard } from "./setup-surface.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { authenticate } from "./urbit/auth.js"; diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts new file mode 100644 index 00000000000..a237a813edf --- /dev/null +++ b/extensions/tlon/src/setup-core.ts @@ -0,0 +1,101 @@ +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { buildTlonAccountFields } from "./account-fields.js"; +import { resolveTlonAccount } from "./types.js"; + +const channel = "tlon" as const; + +export type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + allowPrivateNetwork?: boolean; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + ownerShip?: string; +}; + +export function applyTlonSetupConfig(params: { + cfg: OpenClawConfig; + accountId: string; + input: TlonSetupInput; +}): OpenClawConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + const payload = buildTlonAccountFields(input); + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch: { enabled: base.enabled ?? true }, + accountPatch: { + enabled: true, + ...payload, + }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const tlonSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) { + return "Tlon requires --ship."; + } + if (!url) { + return "Tlon requires --url."; + } + if (!code) { + return "Tlon requires --code."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: input as TlonSetupInput, + }), +}; diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index 4cf0d006ebd..ec6258277bd 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,31 +1,13 @@ -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { buildTlonAccountFields } from "./account-fields.js"; +import { applyTlonSetupConfig, type TlonSetupInput, tlonSetupAdapter } from "./setup-core.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; const channel = "tlon" as const; -type TlonSetupInput = ChannelSetupInput & { - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - ownerShip?: string; -}; - function isConfigured(account: TlonResolvedAccount): boolean { return Boolean(account.ship && account.url && account.code); } @@ -37,83 +19,7 @@ function parseList(value: string): string[] { .filter(Boolean); } -function applyTlonSetupConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: TlonSetupInput; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const base = namedConfig.channels?.tlon ?? {}; - const payload = buildTlonAccountFields(input); - - if (useDefault) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: true, - ...payload, - }, - }, - }; - } - - return patchScopedAccountConfig({ - cfg: namedConfig, - channelKey: channel, - accountId, - patch: { enabled: base.enabled ?? true }, - accountPatch: { - enabled: true, - ...payload, - }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -export const tlonSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ cfg, accountId, input }) => { - const setupInput = input as TlonSetupInput; - const resolved = resolveTlonAccount(cfg, accountId ?? undefined); - const ship = setupInput.ship?.trim() || resolved.ship; - const url = setupInput.url?.trim() || resolved.url; - const code = setupInput.code?.trim() || resolved.code; - if (!ship) { - return "Tlon requires --ship."; - } - if (!url) { - return "Tlon requires --url."; - } - if (!code) { - return "Tlon requires --code."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => - applyTlonSetupConfig({ - cfg, - accountId, - input: input as TlonSetupInput, - }), -}; +export { tlonSetupAdapter } from "./setup-core.js"; export const tlonSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 9a39493cac2..f1415103398 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -28,4 +28,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; +export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; From 6a2efa541be1c5ba46045535110cd2c6d118d907 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:21:40 -0700 Subject: [PATCH 048/331] LINE: split setup adapter helpers --- extensions/line/src/channel.ts | 3 +- extensions/line/src/setup-core.ts | 162 ++++++++++++++++++++++++++ extensions/line/src/setup-surface.ts | 165 ++------------------------- src/plugin-sdk/line.ts | 3 +- 4 files changed, 175 insertions(+), 158 deletions(-) create mode 100644 extensions/line/src/setup-core.ts diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 4c2b51cd6d0..b184ebe8482 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -20,7 +20,8 @@ import { type ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { getLineRuntime } from "./runtime.js"; -import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; +import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupWizard } from "./setup-surface.js"; // LINE channel metadata const meta = { diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts new file mode 100644 index 00000000000..324197c70af --- /dev/null +++ b/extensions/line/src/setup-core.ts @@ -0,0 +1,162 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + listLineAccountIds, + normalizeAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + +const channel = "line" as const; + +export function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +export function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +export const lineSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + patchLineAccountConfig({ + cfg, + accountId, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + clearFields: typedInput.useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: typedInput.useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + } + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + patch: { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + }, +}; + +export { listLineAccountIds }; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 1b7a22dfb11..8c1dca21562 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -5,16 +5,16 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { - listLineAccountIds, - normalizeAccountId, - resolveLineAccount, -} from "../../../src/line/accounts.js"; -import type { LineConfig } from "../../../src/line/types.js"; +import { resolveLineAccount } from "../../../src/line/accounts.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + isLineConfigured, + lineSetupAdapter, + listLineAccountIds, + parseLineAllowFromId, + patchLineAccountConfig, +} from "./setup-core.js"; const channel = "line" as const; @@ -36,75 +36,6 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -function patchLineAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; - const clearFields = params.clearFields ?? []; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextLine = { ...lineConfig } as Record; - for (const field of clearFields) { - delete nextLine[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...nextLine, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; - } - - const nextAccount = { - ...(lineConfig.accounts?.[accountId] ?? {}), - } as Record; - for (const field of clearFields) { - delete nextAccount[field]; - } - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...lineConfig, - ...(params.enabled ? { enabled: true } : {}), - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...nextAccount, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }, - }, - }; -} - -function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); -} - -function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; -} - const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, @@ -119,85 +50,7 @@ const lineDmPolicy: ChannelOnboardingDmPolicy = { }), }; -export const lineSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - patchLineAccountConfig({ - cfg, - accountId, - patch: name?.trim() ? { name: name.trim() } : {}, - }), - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - const normalizedAccountId = normalizeAccountId(accountId); - if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { - return patchLineAccountConfig({ - cfg, - accountId: normalizedAccountId, - enabled: true, - clearFields: typedInput.useEnv - ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] - : undefined, - patch: typedInput.useEnv - ? {} - : { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }); - } - return patchLineAccountConfig({ - cfg, - accountId: normalizedAccountId, - enabled: true, - patch: { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }); - }, -}; +export { lineSetupAdapter } from "./setup-core.js"; export const lineSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index d0c6ffcaf86..6022c2ea318 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -27,7 +27,8 @@ export { buildTokenChannelStatusSummary, } from "./status-helpers.js"; -export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; +export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; +export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { From 38abdea8ce7e7f94b818f046068a35e1d0d38d82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:23:21 +0000 Subject: [PATCH 049/331] fix: restore ci type checks --- extensions/line/src/setup-surface.ts | 68 ++++++++++++++++++++++++++++ scripts/lib/plugin-sdk-entries.d.mts | 13 ++++++ src/plugin-sdk/index.test.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 2 +- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/plugin-sdk-entries.d.mts diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 8c1dca21562..688cbf057e5 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -36,6 +36,74 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; +function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, diff --git a/scripts/lib/plugin-sdk-entries.d.mts b/scripts/lib/plugin-sdk-entries.d.mts new file mode 100644 index 00000000000..e5d493b3d46 --- /dev/null +++ b/scripts/lib/plugin-sdk-entries.d.mts @@ -0,0 +1,13 @@ +export const pluginSdkEntrypoints: string[]; +export const pluginSdkSubpaths: string[]; + +export function buildPluginSdkEntrySources(): Record; +export function buildPluginSdkSpecifiers(): string[]; +export function buildPluginSdkPackageExports(): Record< + string, + { + types: string; + default: string; + } +>; +export function listPluginSdkDistArtifacts(): string[]; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 4e9a8869849..dd99550b122 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -175,7 +175,7 @@ describe("plugin-sdk exports", () => { const { default: importResults } = await import(pathToFileURL(consumerEntry).href); expect(importResults).toEqual( - Object.fromEntries(pluginSdkSpecifiers.map((specifier) => [specifier, "object"])), + Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])), ); } finally { await fs.rm(outDir, { recursive: true, force: true }); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 3315cbe5963..6e4b942b9a9 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -13,7 +13,7 @@ import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); -const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id) => ({ +const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ id, load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), })); From c8576ec78bbc95c7a099abfc5419fd057f057d22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:25:02 +0000 Subject: [PATCH 050/331] fix: resolve line setup rebase drift --- extensions/line/src/setup-core.ts | 2 +- extensions/line/src/setup-surface.ts | 69 ---------------------------- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 324197c70af..67c9c674df5 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -18,7 +18,7 @@ export function patchLineAccountConfig(params: { enabled?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); - const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; const clearFields = params.clearFields ?? []; if (accountId === DEFAULT_ACCOUNT_ID) { diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 688cbf057e5..37167723cf7 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -10,7 +10,6 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { isLineConfigured, - lineSetupAdapter, listLineAccountIds, parseLineAllowFromId, patchLineAccountConfig, @@ -36,74 +35,6 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -function patchLineAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; - const clearFields = params.clearFields ?? []; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextLine = { ...lineConfig } as Record; - for (const field of clearFields) { - delete nextLine[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...nextLine, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; - } - - const nextAccount = { - ...(lineConfig.accounts?.[accountId] ?? {}), - } as Record; - for (const field of clearFields) { - delete nextAccount[field]; - } - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...lineConfig, - ...(params.enabled ? { enabled: true } : {}), - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...nextAccount, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }, - }, - }; -} - -function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); -} - -function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; -} const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, From 6513749ef6d3ffb35ff827ae75991eeb61af4018 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:24:35 -0700 Subject: [PATCH 051/331] Mattermost: split setup adapter helpers --- extensions/mattermost/src/channel.ts | 3 +- extensions/mattermost/src/setup-core.ts | 81 ++++++++++++++++++++ extensions/mattermost/src/setup-surface.ts | 89 ++-------------------- 3 files changed, 90 insertions(+), 83 deletions(-) create mode 100644 extensions/mattermost/src/setup-core.ts diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index b28766d6db9..e8873b93268 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -38,7 +38,8 @@ import { sendMessageMattermost } from "./mattermost/send.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { getMattermostRuntime } from "./runtime.js"; -import { mattermostSetupAdapter, mattermostSetupWizard } from "./setup-surface.js"; +import { mattermostSetupAdapter } from "./setup-core.js"; +import { mattermostSetupWizard } from "./setup-surface.js"; const mattermostMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts new file mode 100644 index 00000000000..946b1af728e --- /dev/null +++ b/extensions/mattermost/src/setup-core.ts @@ -0,0 +1,81 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + hasConfiguredSecretInput, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; + +const channel = "mattermost" as const; + +export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { + const tokenConfigured = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + return tokenConfigured && Boolean(account.baseUrl); +} + +export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { + return resolveMattermostAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); +} + +export const mattermostSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Mattermost env vars can only be used for the default account."; + } + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (input.httpUrl && !baseUrl) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }); + }, +}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index a201a24d82f..2877541bba9 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,90 +1,15 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput } from "openclaw/plugin-sdk/mattermost"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listMattermostAccountIds } from "./mattermost/accounts.js"; import { - listMattermostAccountIds, - resolveMattermostAccount, - type ResolvedMattermostAccount, -} from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; + isMattermostConfigured, + mattermostSetupAdapter, + resolveMattermostAccountWithSecrets, +} from "./setup-core.js"; const channel = "mattermost" as const; - -function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { - const tokenConfigured = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - return tokenConfigured && Boolean(account.baseUrl); -} - -function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { - return resolveMattermostAccount({ - cfg, - accountId, - allowUnresolvedSecretRef: true, - }); -} - -export const mattermostSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Mattermost env vars can only be used for the default account."; - } - if (!input.useEnv && (!token || !baseUrl)) { - return "Mattermost requires --bot-token and --http-url (or --use-env)."; - } - if (input.httpUrl && !baseUrl) { - return "Mattermost --http-url must include a valid base URL."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }, - }); - }, -}; +export { mattermostSetupAdapter } from "./setup-core.js"; export const mattermostSetupWizard: ChannelSetupWizard = { channel, From 47a9c1a8934209281f0f10b13c81b5f5cd0d33da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:26:11 +0000 Subject: [PATCH 052/331] refactor: merge minimax bundled plugins --- CHANGELOG.md | 1 + docs/providers/minimax.md | 4 +- docs/tools/plugin.md | 5 +- extensions/minimax-portal-auth/README.md | 33 --- extensions/minimax-portal-auth/index.ts | 163 --------------- .../minimax-portal-auth/openclaw.plugin.json | 9 - extensions/minimax-portal-auth/package.json | 12 -- extensions/minimax/README.md | 37 ++++ extensions/minimax/index.ts | 195 ++++++++++++++++-- .../{minimax-portal-auth => minimax}/oauth.ts | 20 +- extensions/minimax/openclaw.plugin.json | 2 +- extensions/minimax/package.json | 2 +- scripts/check-no-raw-channel-fetch.mjs | 4 +- src/commands/auth-choice.apply.minimax.ts | 4 +- src/config/plugin-auto-enable.test.ts | 19 ++ src/config/plugin-auto-enable.ts | 2 +- src/plugin-sdk/minimax-portal-auth.ts | 4 +- src/plugins/config-state.test.ts | 12 +- src/plugins/config-state.ts | 2 +- src/plugins/providers.ts | 1 - 20 files changed, 258 insertions(+), 273 deletions(-) delete mode 100644 extensions/minimax-portal-auth/README.md delete mode 100644 extensions/minimax-portal-auth/index.ts delete mode 100644 extensions/minimax-portal-auth/openclaw.plugin.json delete mode 100644 extensions/minimax-portal-auth/package.json create mode 100644 extensions/minimax/README.md rename extensions/{minimax-portal-auth => minimax}/oauth.ts (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1d5cf8998..20d0b32ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. - Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. +- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 8cdc5b028f6..0d3635352cc 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -42,7 +42,7 @@ MiniMax highlights these improvements in M2.5: Enable the bundled OAuth plugin and authenticate: ```bash -openclaw plugins enable minimax-portal-auth # skip if already loaded. +openclaw plugins enable minimax # skip if already loaded. openclaw gateway restart # restart if gateway is already running openclaw onboard --auth-choice minimax-portal ``` @@ -52,7 +52,7 @@ You will be prompted to select an endpoint: - **Global** - International users (`api.minimax.io`) - **CN** - Users in China (`api.minimaxi.com`) -See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details. +See [MiniMax plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax) for details. ### MiniMax M2.5 (API key) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2a5b5d37006..91613cbe731 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -172,8 +172,7 @@ Important trust note: - Hugging Face provider catalog — bundled as `huggingface` (enabled by default) - Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) - Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default) -- MiniMax provider catalog + usage — bundled as `minimax` (enabled by default) -- MiniMax OAuth (provider auth + catalog) — bundled as `minimax-portal-auth` (enabled by default) +- MiniMax provider catalog + usage + OAuth — bundled as `minimax` (enabled by default; owns `minimax` and `minimax-portal`) - Mistral provider capabilities — bundled as `mistral` (enabled by default) - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) @@ -664,7 +663,7 @@ Default-on bundled plugin examples: - `kilocode` - `kimi-coding` - `minimax` -- `minimax-portal-auth` +- `minimax` - `modelstudio` - `moonshot` - `nvidia` diff --git a/extensions/minimax-portal-auth/README.md b/extensions/minimax-portal-auth/README.md deleted file mode 100644 index 3c29ab8ac22..00000000000 --- a/extensions/minimax-portal-auth/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# MiniMax OAuth (OpenClaw plugin) - -OAuth provider plugin for **MiniMax** (OAuth). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable minimax-portal-auth -``` - -Restart the Gateway after enabling. - -```bash -openclaw gateway restart -``` - -## Authenticate - -```bash -openclaw models auth login --provider minimax-portal --set-default -``` - -You will be prompted to select an endpoint: - -- **Global** - International users, optimized for overseas access (`api.minimax.io`) -- **China** - Optimized for users in China (`api.minimaxi.com`) - -## Notes - -- MiniMax OAuth uses a user-code login flow. -- Currently, OAuth login is supported only for the Coding plan diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts deleted file mode 100644 index eda0b72227c..00000000000 --- a/extensions/minimax-portal-auth/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderAuthContext, - type ProviderAuthResult, - type ProviderCatalogContext, -} from "openclaw/plugin-sdk/minimax-portal-auth"; -import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; -import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; -import { buildMinimaxPortalProvider } from "../../src/agents/models-config.providers.static.js"; -import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; - -const PROVIDER_ID = "minimax-portal"; -const PROVIDER_LABEL = "MiniMax"; -const DEFAULT_MODEL = "MiniMax-M2.5"; -const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; -const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; - -function getDefaultBaseUrl(region: MiniMaxRegion): string { - return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; -} - -function modelRef(modelId: string): string { - return `${PROVIDER_ID}/${modelId}`; -} - -function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { - return { - ...buildMinimaxPortalProvider(), - baseUrl: params.baseUrl, - apiKey: params.apiKey, - }; -} - -function resolveCatalog(ctx: ProviderCatalogContext) { - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const envApiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const hasProfiles = listProfilesForProvider(authStore, PROVIDER_ID).length > 0; - const explicitApiKey = - typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; - const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); - if (!apiKey) { - return null; - } - - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; - - return { - provider: buildProviderCatalog({ - baseUrl: explicitBaseUrl || DEFAULT_BASE_URL_GLOBAL, - apiKey, - }), - }; -} - -function createOAuthHandler(region: MiniMaxRegion) { - const defaultBaseUrl = getDefaultBaseUrl(region); - const regionLabel = region === "cn" ? "CN" : "Global"; - - return async (ctx: ProviderAuthContext): Promise => { - const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); - try { - const result = await loginMiniMaxPortalOAuth({ - openUrl: ctx.openUrl, - note: ctx.prompter.note, - progress, - region, - }); - - progress.stop("MiniMax OAuth complete"); - - if (result.notification_message) { - await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); - } - - const baseUrl = result.resourceUrl || defaultBaseUrl; - - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: modelRef(DEFAULT_MODEL), - access: result.access, - refresh: result.refresh, - expires: result.expires, - configPatch: { - models: { - providers: { - [PROVIDER_ID]: { - baseUrl, - models: [], - }, - }, - }, - agents: { - defaults: { - models: { - [modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, - [modelRef("MiniMax-M2.5-highspeed")]: { - alias: "minimax-m2.5-highspeed", - }, - [modelRef("MiniMax-M2.5-Lightning")]: { - alias: "minimax-m2.5-lightning", - }, - }, - }, - }, - }, - notes: [ - "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", - `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, - ...(result.notification_message ? [result.notification_message] : []), - ], - }); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - progress.stop(`MiniMax OAuth failed: ${errorMsg}`); - await ctx.prompter.note( - "If OAuth fails, verify your MiniMax account has portal access and try again.", - "MiniMax OAuth", - ); - throw err; - } - }; -} - -const minimaxPortalPlugin = { - id: "minimax-portal-auth", - name: "MiniMax OAuth", - description: "OAuth flow for MiniMax models", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: PROVIDER_LABEL, - docsPath: "/providers/minimax", - catalog: { - run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), - }, - auth: [ - { - id: "oauth", - label: "MiniMax OAuth (Global)", - hint: "Global endpoint - api.minimax.io", - kind: "device_code", - run: createOAuthHandler("global"), - }, - { - id: "oauth-cn", - label: "MiniMax OAuth (CN)", - hint: "CN endpoint - api.minimaxi.com", - kind: "device_code", - run: createOAuthHandler("cn"), - }, - ], - }); - }, -}; - -export default minimaxPortalPlugin; diff --git a/extensions/minimax-portal-auth/openclaw.plugin.json b/extensions/minimax-portal-auth/openclaw.plugin.json deleted file mode 100644 index 4645b6907eb..00000000000 --- a/extensions/minimax-portal-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "minimax-portal-auth", - "providers": ["minimax-portal"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json deleted file mode 100644 index 093d42dad1d..00000000000 --- a/extensions/minimax-portal-auth/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/minimax-portal-auth", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw MiniMax Portal OAuth provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/minimax/README.md b/extensions/minimax/README.md new file mode 100644 index 00000000000..e38b7c16c68 --- /dev/null +++ b/extensions/minimax/README.md @@ -0,0 +1,37 @@ +# MiniMax (OpenClaw plugin) + +Bundled MiniMax plugin for both: + +- API-key provider setup (`minimax`) +- Coding Plan OAuth setup (`minimax-portal`) + +## Enable + +```bash +openclaw plugins enable minimax +``` + +Restart the Gateway after enabling. + +```bash +openclaw gateway restart +``` + +## Authenticate + +OAuth: + +```bash +openclaw models auth login --provider minimax-portal --set-default +``` + +API key: + +```bash +openclaw onboard --auth-choice minimax-global-api +``` + +## Notes + +- MiniMax OAuth uses a user-code login flow. +- OAuth currently targets the Coding Plan path. diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 6585e27d7cf..969868986f0 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,35 +1,165 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildMinimaxProvider } from "../../src/agents/models-config.providers.static.js"; +import { + buildOauthProviderAuthResult, + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthResult, + type ProviderCatalogContext, +} from "openclaw/plugin-sdk/minimax-portal-auth"; +import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; +import { + buildMinimaxPortalProvider, + buildMinimaxProvider, +} from "../../src/agents/models-config.providers.static.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; +import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; -const PROVIDER_ID = "minimax"; +const API_PROVIDER_ID = "minimax"; +const PORTAL_PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = "MiniMax-M2.5"; +const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; +const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; + +function getDefaultBaseUrl(region: MiniMaxRegion): string { + return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; +} + +function modelRef(modelId: string): string { + return `${PORTAL_PROVIDER_ID}/${modelId}`; +} + +function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { + return { + ...buildMinimaxPortalProvider(), + baseUrl: params.baseUrl, + apiKey: params.apiKey, + }; +} + +function resolveApiCatalog(ctx: ProviderCatalogContext) { + const apiKey = ctx.resolveProviderApiKey(API_PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildMinimaxProvider(), + apiKey, + }, + }; +} + +function resolvePortalCatalog(ctx: ProviderCatalogContext) { + const explicitProvider = ctx.config.models?.providers?.[PORTAL_PROVIDER_ID]; + const envApiKey = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID).apiKey; + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; + const explicitApiKey = + typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); + if (!apiKey) { + return null; + } + + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; + + return { + provider: buildPortalProviderCatalog({ + baseUrl: explicitBaseUrl || DEFAULT_BASE_URL_GLOBAL, + apiKey, + }), + }; +} + +function createOAuthHandler(region: MiniMaxRegion) { + const defaultBaseUrl = getDefaultBaseUrl(region); + const regionLabel = region === "cn" ? "CN" : "Global"; + + return async (ctx: ProviderAuthContext): Promise => { + const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); + try { + const result = await loginMiniMaxPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + region, + }); + + progress.stop("MiniMax OAuth complete"); + + if (result.notification_message) { + await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); + } + + const baseUrl = result.resourceUrl || defaultBaseUrl; + + return buildOauthProviderAuthResult({ + providerId: PORTAL_PROVIDER_ID, + defaultModel: modelRef(DEFAULT_MODEL), + access: result.access, + refresh: result.refresh, + expires: result.expires, + configPatch: { + models: { + providers: { + [PORTAL_PROVIDER_ID]: { + baseUrl, + models: [], + }, + }, + }, + agents: { + defaults: { + models: { + [modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, + [modelRef("MiniMax-M2.5-highspeed")]: { + alias: "minimax-m2.5-highspeed", + }, + [modelRef("MiniMax-M2.5-Lightning")]: { + alias: "minimax-m2.5-lightning", + }, + }, + }, + }, + }, + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PORTAL_PROVIDER_ID}.baseUrl if needed.`, + ...(result.notification_message ? [result.notification_message] : []), + ], + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.stop(`MiniMax OAuth failed: ${errorMsg}`); + await ctx.prompter.note( + "If OAuth fails, verify your MiniMax account has portal access and try again.", + "MiniMax OAuth", + ); + throw err; + } + }; +} const minimaxPlugin = { - id: PROVIDER_ID, - name: "MiniMax Provider", - description: "Bundled MiniMax provider plugin", + id: API_PROVIDER_ID, + name: "MiniMax", + description: "Bundled MiniMax API-key and OAuth provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ - id: PROVIDER_ID, - label: "MiniMax", + id: API_PROVIDER_ID, + label: PROVIDER_LABEL, docsPath: "/providers/minimax", envVars: ["MINIMAX_API_KEY"], auth: [], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildMinimaxProvider(), - apiKey, - }, - }; - }, + run: async (ctx) => resolveApiCatalog(ctx), }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ @@ -40,6 +170,31 @@ const minimaxPlugin = { fetchUsageSnapshot: async (ctx) => await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); + + api.registerProvider({ + id: PORTAL_PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/minimax", + catalog: { + run: async (ctx) => resolvePortalCatalog(ctx), + }, + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + kind: "device_code", + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "device_code", + run: createOAuthHandler("cn"), + }, + ], + }); }, }; diff --git a/extensions/minimax-portal-auth/oauth.ts b/extensions/minimax/oauth.ts similarity index 90% rename from extensions/minimax-portal-auth/oauth.ts rename to extensions/minimax/oauth.ts index 5b18c13d3a4..fb405cd5559 100644 --- a/extensions/minimax-portal-auth/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -161,7 +161,7 @@ async function pollOAuthToken(params: { return { status: "error", message: "An error occurred. Please try again later" }; } - if (tokenPayload.status != "success") { + if (tokenPayload.status !== "success") { return { status: "pending", message: "current user code is not authorized" }; } @@ -216,29 +216,17 @@ export async function loginMiniMaxPortalOAuth(params: { region, }); - // // Debug: print poll result - // await params.note( - // `status: ${result.status}` + - // (result.status === "success" ? `\ntoken: ${JSON.stringify(result.token, null, 2)}` : "") + - // (result.status === "error" ? `\nmessage: ${result.message}` : "") + - // (result.status === "pending" && result.message ? `\nmessage: ${result.message}` : ""), - // "MiniMax OAuth Poll Result", - // ); - if (result.status === "success") { return result.token; } if (result.status === "error") { - throw new Error(`MiniMax OAuth failed: ${result.message}`); - } - - if (result.status === "pending") { - pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000); + throw new Error(result.message); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + pollIntervalMs = Math.max(pollIntervalMs, 2000); } - throw new Error("MiniMax OAuth timed out waiting for authorization."); + throw new Error("MiniMax OAuth timed out before authorization completed."); } diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 01f3e5efbea..32d8be58bf5 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -1,6 +1,6 @@ { "id": "minimax", - "providers": ["minimax"], + "providers": ["minimax", "minimax-portal"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/minimax/package.json b/extensions/minimax/package.json index 6650cf1e456..f6c99e0e756 100644 --- a/extensions/minimax/package.json +++ b/extensions/minimax/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/minimax-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw MiniMax provider plugin", + "description": "OpenClaw MiniMax provider and OAuth plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 7b935d183e5..57adb600c81 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -24,8 +24,8 @@ const allowedRawFetchCallsites = new Set([ "extensions/mattermost/src/mattermost/client.ts:211", "extensions/mattermost/src/mattermost/monitor.ts:230", "extensions/mattermost/src/mattermost/probe.ts:27", - "extensions/minimax-portal-auth/oauth.ts:71", - "extensions/minimax-portal-auth/oauth.ts:112", + "extensions/minimax/oauth.ts:62", + "extensions/minimax/oauth.ts:93", "extensions/msteams/src/graph.ts:39", "extensions/nextcloud-talk/src/room-info.ts:92", "extensions/nextcloud-talk/src/send.ts:107", diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 1a381b908b8..6438b94c043 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -22,7 +22,7 @@ export async function applyAuthChoiceMiniMax( if (params.authChoice === "minimax-global-oauth") { return await applyAuthChoicePluginProvider(params, { authChoice: "minimax-global-oauth", - pluginId: "minimax-portal-auth", + pluginId: "minimax", providerId: "minimax-portal", methodId: "oauth", label: "MiniMax", @@ -32,7 +32,7 @@ export async function applyAuthChoiceMiniMax( if (params.authChoice === "minimax-cn-oauth") { return await applyAuthChoicePluginProvider(params, { authChoice: "minimax-cn-oauth", - pluginId: "minimax-portal-auth", + pluginId: "minimax", providerId: "minimax-portal", methodId: "oauth-cn", label: "MiniMax CN", diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index cae9b4e5c18..8439a2768ec 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -310,6 +310,25 @@ describe("applyPluginAutoEnable", () => { expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); + it("auto-enables minimax when minimax-portal profiles exist", () => { + const result = applyPluginAutoEnable({ + config: { + auth: { + profiles: { + "minimax-portal:default": { + provider: "minimax-portal", + mode: "oauth", + }, + }, + }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined(); + }); + it("auto-enables acpx plugin when ACP is configured", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 72e1dede1ef..2a7524b2558 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -31,7 +31,7 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, - { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, + { pluginId: "minimax", providerId: "minimax-portal" }, ]; function hasNonEmptyString(value: unknown): boolean { diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index cc41b2cc80d..07aefa0aafa 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled minimax-portal-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth. +// Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin. +// Keep this list additive and scoped to MiniMax OAuth support code. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 37db8a6efae..c4195a5e6e3 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -80,18 +80,22 @@ describe("normalizePluginsConfig", () => { it("normalizes legacy plugin ids to their merged bundled plugin id", () => { const result = normalizePluginsConfig({ - allow: ["openai-codex"], - deny: ["openai-codex"], + allow: ["openai-codex", "minimax-portal-auth"], + deny: ["openai-codex", "minimax-portal-auth"], entries: { "openai-codex": { enabled: true, }, + "minimax-portal-auth": { + enabled: false, + }, }, }); - expect(result.allow).toEqual(["openai"]); - expect(result.deny).toEqual(["openai"]); + expect(result.allow).toEqual(["openai", "minimax"]); + expect(result.deny).toEqual(["openai", "minimax"]); expect(result.entries.openai?.enabled).toBe(true); + expect(result.entries.minimax?.enabled).toBe(false); }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index a5860b606e3..493ad885f51 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,7 +33,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "kilocode", "kimi-coding", "minimax", - "minimax-portal-auth", "mistral", "modelstudio", "moonshot", @@ -60,6 +59,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ const PLUGIN_ID_ALIASES: Readonly> = { "openai-codex": "openai", + "minimax-portal-auth": "minimax", }; function normalizePluginId(id: string): string { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 4f4216730cf..c1de0680359 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -16,7 +16,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "kilocode", "kimi-coding", "minimax", - "minimax-portal-auth", "mistral", "modelstudio", "moonshot", From bcdbd03579e4fdb8c1c411f882e590dec111ef0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:26:15 +0000 Subject: [PATCH 053/331] docs: refresh zh-CN model providers --- docs/zh-CN/concepts/model-providers.md | 433 +++++++++++++++++++------ 1 file changed, 334 insertions(+), 99 deletions(-) diff --git a/docs/zh-CN/concepts/model-providers.md b/docs/zh-CN/concepts/model-providers.md index ba345d18743..716e007a3ba 100644 --- a/docs/zh-CN/concepts/model-providers.md +++ b/docs/zh-CN/concepts/model-providers.md @@ -1,12 +1,12 @@ --- read_when: - - 你需要按提供商分类的模型设置参考 + - 你需要一份逐提供商的模型设置参考 - 你需要模型提供商的示例配置或 CLI 新手引导命令 -summary: 模型提供商概述,包含示例配置和 CLI 流程 +summary: 模型提供商概览,包含示例配置和 CLI 流程 title: 模型提供商 x-i18n: - generated_at: "2026-03-16T01:39:16Z" - model: claude-opus-4-5 + generated_at: "2026-03-16T02:12:40Z" + model: claude-opus-4-6 provider: pi source_hash: 978798c80c5809c162f9807072ab48fdf99bfe0db39b2b3c245ce8b4e5451603 source_path: concepts/model-providers.md @@ -15,137 +15,251 @@ x-i18n: # 模型提供商 -本页介绍 **LLM/模型提供商**(不是 WhatsApp/Telegram 等聊天渠道)。 -关于模型选择规则,请参阅 [/concepts/models](/concepts/models)。 +本页涵盖 **LLM/模型提供商** (不是 WhatsApp/Telegram 等聊天渠道)。 +有关模型选择规则,请参阅 [/concepts/models](/concepts/models)。 ## 快速规则 -- 模型引用使用 `provider/model` 格式(例如:`opencode/claude-opus-4-5`)。 -- 如果设置了 `agents.defaults.models`,它将成为允许列表。 -- CLI 辅助工具:`openclaw onboard`、`openclaw models list`、`openclaw models set `。 +- 模型引用使用 `provider/model` (例如: `opencode/claude-opus-4-6`)。 +- 如果你设置了 `agents.defaults.models`,它将成为允许列表。 +- CLI 辅助命令: `openclaw onboard`, `openclaw models list`, `openclaw models set `。 +- 提供商插件可以通过以下方式注入模型目录 `registerProvider({ catalog })`; + OpenClaw 将该输出合并到 `models.providers` 之后再写入 + `models.json`。 +- 提供商插件还可以通过以下方式控制提供商的运行时行为 + `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, + `capabilities`, `prepareExtraParams`, `wrapStreamFn`, + `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`,以及 + `fetchUsageSnapshot`。 + +## 插件管理的提供商行为 + +提供商插件现在可以管理大部分提供商特定逻辑,而 OpenClaw 负责维护通用推理循环。 + +典型分工: + +- `catalog`:提供商出现在 `models.providers` +- `resolveDynamicModel`:提供商接受尚未出现在本地静态目录中的模型 ID +- `prepareDynamicModel`:提供商在重试动态解析之前需要刷新元数据 +- `normalizeResolvedModel`:提供商需要传输层或基础 URL 重写 +- `capabilities`:提供商发布会话记录/工具/提供商系列的特殊行为 +- `prepareExtraParams`:提供商默认或规范化每个模型的请求参数 +- `wrapStreamFn`:提供商应用请求头/请求体/模型兼容性封装 +- `isCacheTtlEligible`:提供商决定哪些上游模型 ID 支持 prompt-cache TTL +- `prepareRuntimeAuth`:提供商将配置的凭证转换为短期运行时令牌 +- `resolveUsageAuth`:提供商为以下用途解析使用量/配额凭证 `/usage` + 以及相关的状态/报告界面 +- `fetchUsageSnapshot`:提供商负责使用量端点的获取/解析,而核心仍负责摘要外壳和格式化 + +当前内置示例: + +- `anthropic`:Claude 4.6 向前兼容回退、使用量端点获取,以及 cache-TTL/提供商系列元数据 +- `openrouter`:直通模型 ID、请求封装、提供商能力提示,以及 cache-TTL 策略 +- `github-copilot`:向前兼容模型回退、Claude-thinking 会话记录提示、运行时令牌交换,以及使用量端点获取 +- `openai`:GPT-5.4 向前兼容回退、直接 OpenAI 传输规范化,以及提供商系列元数据 +- `openai-codex`:向前兼容模型回退、传输规范化,以及默认传输参数和使用量端点获取 +- `google-gemini-cli`:Gemini 3.1 向前兼容回退,以及使用量界面的 usage-token 解析和配额端点获取 +- `moonshot`:共享传输、插件管理的 thinking 负载规范化 +- `kilocode`:共享传输、插件管理的请求头、推理负载规范化、Gemini 会话记录提示,以及 cache-TTL 策略 +- `zai`:GLM-5 向前兼容回退, `tool_stream` 默认值、cache-TTL 策略,以及使用量认证和配额获取 +- `mistral`, `opencode`,以及`opencode-go`:插件管理的能力元数据 +- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, + `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, + `synthetic`, `together`, `venice`, `vercel-ai-gateway`,以及`volcengine`:仅限插件管理的目录 +- `minimax` 和 `xiaomi`:插件管理的目录以及使用量认证/快照逻辑 + +以上涵盖了仍然适用于 OpenClaw 常规传输层的提供商。如果某个提供商需要完全自定义的请求执行器,则属于一个独立的、更深层的扩展层面。 + +## API 密钥轮换 + +- 支持对选定提供商的通用提供商轮换。 +- 通过以下方式配置多个密钥: + - `OPENCLAW_LIVE__KEY` (单个实时覆盖,最高优先级) + - `_API_KEYS` (逗号或分号分隔的列表) + - `_API_KEY` (主密钥) + - `_API_KEY_*` (编号列表,例如 `_API_KEY_1`) +- 对于 Google 提供商, `GOOGLE_API_KEY` 也作为备选项包含在内。 +- 密钥选择顺序按优先级排列并去除重复值。 +- 仅在速率限制响应时使用下一个密钥重试请求(例如 `429`, `rate_limit`, `quota`, `resource exhausted`)。 +- 非速率限制的失败会立即报错;不会尝试密钥轮换。 +- 当所有候选密钥均失败时,返回最后一次尝试的错误。 ## 内置提供商(pi-ai 目录) -OpenClaw 附带 pi-ai 目录。这些提供商**不需要** `models.providers` 配置;只需设置认证 + 选择模型。 +OpenClaw 附带 pi-ai 目录。这些提供商需要 **无需** +`models.providers` 配置;只需设置认证并选择一个模型。 ### OpenAI -- 提供商:`openai` -- 认证:`OPENAI_API_KEY` -- 示例模型:`openai/gpt-5.2` -- CLI:`openclaw onboard --auth-choice openai-api-key` +- 提供商: `openai` +- 认证: `OPENAI_API_KEY` +- 可选轮换: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`,加上 `OPENCLAW_LIVE_OPENAI_KEY` (单个覆盖) +- 示例模型: `openai/gpt-5.4`, `openai/gpt-5.4-pro` +- CLI: `openclaw onboard --auth-choice openai-api-key` +- 默认传输为 `auto` (WebSocket 优先,SSE 备选) +- 通过以下方式覆盖每个模型 `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`) +- OpenAI Responses WebSocket 预热默认通过以下方式启用 `params.openaiWsWarmup` (`true`/`false`) +- OpenAI 优先处理可以通过以下方式启用 `agents.defaults.models["openai/"].params.serviceTier` +- OpenAI 快速模式可以通过以下方式为每个模型启用 `agents.defaults.models["/"].params.fastMode` +- `openai/gpt-5.3-codex-spark` 在 OpenClaw 中被有意屏蔽,因为 OpenAI 实时 API 会拒绝它;Spark 被视为仅限 Codex 使用 ```json5 { - agents: { defaults: { model: { primary: "openai/gpt-5.2" } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, } ``` ### Anthropic -- 提供商:`anthropic` -- 认证:`ANTHROPIC_API_KEY` 或 `claude setup-token` -- 示例模型:`anthropic/claude-opus-4-5` -- CLI:`openclaw onboard --auth-choice token`(粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic` +- 提供商: `anthropic` +- 认证: `ANTHROPIC_API_KEY` 或 `claude setup-token` +- 可选轮换: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`,加上 `OPENCLAW_LIVE_ANTHROPIC_KEY` (单个覆盖) +- 示例模型: `anthropic/claude-opus-4-6` +- CLI: `openclaw onboard --auth-choice token` (粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic` +- 直接 API 密钥模型支持共享的 `/fast` 切换和 `params.fastMode`;OpenClaw 将其映射到 Anthropic 的 `service_tier` (`auto` 与 `standard_only`) +- 策略说明:setup-token 支持属于技术兼容性;Anthropic 过去曾阻止部分订阅在 Claude Code 之外的使用。请核实当前 Anthropic 条款,并根据你的风险承受能力做出决定。 +- 建议:Anthropic API 密钥认证是比订阅 setup-token 认证更安全的推荐方式。 ```json5 { - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } }, } ``` ### OpenAI Code (Codex) -- 提供商:`openai-codex` +- 提供商: `openai-codex` - 认证:OAuth (ChatGPT) -- 示例模型:`openai-codex/gpt-5.2` -- CLI:`openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex` +- 示例模型: `openai-codex/gpt-5.4` +- CLI: `openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex` +- 默认传输为 `auto` (WebSocket 优先,SSE 备选) +- 通过以下方式覆盖每个模型 `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`) +- 与相同的 `/fast` 切换和 `params.fastMode` 配置共享,如同直接的 `openai/*` +- `openai-codex/gpt-5.3-codex-spark` 当 Codex OAuth 目录公开时仍然可用;取决于授权资格 +- 策略说明:OpenAI Codex OAuth 明确支持 OpenClaw 等外部工具/工作流。 ```json5 { - agents: { defaults: { model: { primary: "openai-codex/gpt-5.2" } } }, + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, } ``` -### OpenCode Zen +### OpenCode -- 提供商:`opencode` -- 认证:`OPENCODE_API_KEY`(或 `OPENCODE_ZEN_API_KEY`) -- 示例模型:`opencode/claude-opus-4-5` -- CLI:`openclaw onboard --auth-choice opencode-zen` +- 认证: `OPENCODE_API_KEY` (或 `OPENCODE_ZEN_API_KEY`) +- Zen 运行时提供商: `opencode` +- Go 运行时提供商: `opencode-go` +- 示例模型: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5` +- CLI: `openclaw onboard --auth-choice opencode-zen` 或 `openclaw onboard --auth-choice opencode-go` ```json5 { - agents: { defaults: { model: { primary: "opencode/claude-opus-4-5" } } }, + agents: { defaults: { model: { primary: "opencode/claude-opus-4-6" } } }, } ``` ### Google Gemini(API 密钥) -- 提供商:`google` -- 认证:`GEMINI_API_KEY` -- 示例模型:`google/gemini-3-pro-preview` -- CLI:`openclaw onboard --auth-choice gemini-api-key` +- 提供商: `google` +- 认证: `GEMINI_API_KEY` +- 可选轮换: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` 备选,以及 `OPENCLAW_LIVE_GEMINI_KEY` (单个覆盖) +- 示例模型: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview` +- 兼容性:使用旧版 OpenClaw 配置的 `google/gemini-3.1-flash-preview` 会被规范化为 `google/gemini-3-flash-preview` +- CLI: `openclaw onboard --auth-choice gemini-api-key` ### Google Vertex 和 Gemini CLI -- 提供商:`google-vertex`、`google-gemini-cli` +- 提供商: `google-vertex`, `google-gemini-cli` - 认证:Vertex 使用 gcloud ADC;Gemini CLI 使用其 OAuth 流程 -- 注意:OpenClaw 中的 Gemini CLI OAuth 属于非官方集成。一些用户报告称,在第三方客户端中使用后其 Google 账号受到了限制。继续前请先查看 Google 条款,并尽量使用非关键账号。 -- Gemini CLI OAuth 作为捆绑 `google` 插件的一部分提供。 - - 启用:`openclaw plugins enable google` - - 登录:`openclaw models auth login --provider google-gemini-cli --set-default` - - 注意:你**不需要**将客户端 ID 或密钥粘贴到 `openclaw.json` 中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 +- 注意:OpenClaw 中的 Gemini CLI OAuth 是非官方集成。部分用户报告称在使用第三方客户端后 Google 账户受到限制。请查阅 Google 条款,如果你选择继续,建议使用非关键账户。 +- Gemini CLI OAuth 作为内置的 `google` 插件的一部分提供。 + - 启用: `openclaw plugins enable google` + - 登录: `openclaw models auth login --provider google-gemini-cli --set-default` + - 注意:你确实 **不** 需要将 client ID 或 secret 粘贴到 `openclaw.json`中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 ### Z.AI (GLM) -- 提供商:`zai` -- 认证:`ZAI_API_KEY` -- 示例模型:`zai/glm-4.7` -- CLI:`openclaw onboard --auth-choice zai-api-key` - - 别名:`z.ai/*` 和 `z-ai/*` 规范化为 `zai/*` +- 提供商: `zai` +- 认证: `ZAI_API_KEY` +- 示例模型: `zai/glm-5` +- CLI: `openclaw onboard --auth-choice zai-api-key` + - 别名: `z.ai/*` 和 `z-ai/*` 规范化为 `zai/*` ### Vercel AI Gateway -- 提供商:`vercel-ai-gateway` -- 认证:`AI_GATEWAY_API_KEY` -- 示例模型:`vercel-ai-gateway/anthropic/claude-opus-4.5` -- CLI:`openclaw onboard --auth-choice ai-gateway-api-key` +- 提供商: `vercel-ai-gateway` +- 认证: `AI_GATEWAY_API_KEY` +- 示例模型: `vercel-ai-gateway/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --auth-choice ai-gateway-api-key` -### 其他内置提供商 +### Kilo Gateway -- OpenRouter:`openrouter`(`OPENROUTER_API_KEY`) -- 示例模型:`openrouter/anthropic/claude-sonnet-4-5` -- xAI:`xai`(`XAI_API_KEY`) -- Groq:`groq`(`GROQ_API_KEY`) -- Cerebras:`cerebras`(`CEREBRAS_API_KEY`) +- 提供商: `kilocode` +- 认证: `KILOCODE_API_KEY` +- 示例模型: `kilocode/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --kilocode-api-key ` +- 基础 URL: `https://api.kilo.ai/api/gateway/` +- 扩展的内置目录包括 GLM-5 Free、MiniMax M2.5 Free、GPT-5.2、Gemini 3 Pro Preview、Gemini 3 Flash Preview、Grok Code Fast 1 和 Kimi K2.5。 + +参阅 [/providers/kilocode](/providers/kilocode) 了解详情。 + +### 其他内置提供商插件 + +- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) +- 示例模型: `openrouter/anthropic/claude-sonnet-4-5` +- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) +- 示例模型: `kilocode/anthropic/claude-opus-4.6` +- MiniMax: `minimax` (`MINIMAX_API_KEY`) +- Moonshot: `moonshot` (`MOONSHOT_API_KEY`) +- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` 或 `KIMICODE_API_KEY`) +- Qianfan: `qianfan` (`QIANFAN_API_KEY`) +- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`) +- NVIDIA: `nvidia` (`NVIDIA_API_KEY`) +- Together: `together` (`TOGETHER_API_KEY`) +- Venice: `venice` (`VENICE_API_KEY`) +- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`) +- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`) +- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` 或 `HF_TOKEN`) +- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`) +- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`) +- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`) +- xAI: `xai` (`XAI_API_KEY`) +- Mistral: `mistral` (`MISTRAL_API_KEY`) +- 示例模型: `mistral/mistral-large-latest` +- CLI: `openclaw onboard --auth-choice mistral-api-key` +- Groq: `groq` (`GROQ_API_KEY`) +- Cerebras: `cerebras` (`CEREBRAS_API_KEY`) - Cerebras 上的 GLM 模型使用 ID `zai-glm-4.7` 和 `zai-glm-4.6`。 - - OpenAI 兼容的基础 URL:`https://api.cerebras.ai/v1`。 -- Mistral:`mistral`(`MISTRAL_API_KEY`) -- GitHub Copilot:`github-copilot`(`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`) + - 兼容 OpenAI 的基础 URL: `https://api.cerebras.ai/v1`。 +- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`) +- Hugging Face Inference 示例模型: `huggingface/deepseek-ai/DeepSeek-R1`;CLI: `openclaw onboard --auth-choice huggingface-api-key`。参阅 [Hugging Face (Inference)](/providers/huggingface)。 -## 通过 `models.providers` 配置的提供商(自定义/基础 URL) +## 通过以下方式提供的提供商 `models.providers` (自定义/基础 URL) -使用 `models.providers`(或 `models.json`)添加**自定义**提供商或 OpenAI/Anthropic 兼容的代理。 +使用 `models.providers` (或 `models.json`)来添加 **自定义** 提供商或 OpenAI/Anthropic 兼容代理。 + +下方许多内置提供商插件已经发布了默认目录。 +使用显式的 `models.providers.` 条目仅在你需要覆盖默认基础 URL、请求头或模型列表时使用。 ### Moonshot AI (Kimi) -Moonshot 使用 OpenAI 兼容端点,因此将其配置为自定义提供商: +Moonshot 使用兼容 OpenAI 的端点,因此将其配置为自定义提供商: -- 提供商:`moonshot` -- 认证:`MOONSHOT_API_KEY` -- 示例模型:`moonshot/kimi-k2.5` +- 提供商: `moonshot` +- 认证: `MOONSHOT_API_KEY` +- 示例模型: `moonshot/kimi-k2.5` Kimi K2 模型 ID: -{/_ moonshot-kimi-k2-model-refs:start _/ && null} +[//]: # "moonshot-kimi-k2-model-refs:start" - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-thinking` - `moonshot/kimi-k2-thinking-turbo` - {/_ moonshot-kimi-k2-model-refs:end _/ && null} + +[//]: # "moonshot-kimi-k2-model-refs:end" ```json5 { @@ -170,9 +284,9 @@ Kimi K2 模型 ID: Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点: -- 提供商:`kimi-coding` -- 认证:`KIMI_API_KEY` -- 示例模型:`kimi-coding/k2p5` +- 提供商: `kimi-coding` +- 认证: `KIMI_API_KEY` +- 示例模型: `kimi-coding/k2p5` ```json5 { @@ -183,13 +297,12 @@ Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点: } ``` -### Qwen OAuth(免费层级) +### Qwen OAuth(免费套餐) Qwen 通过设备码流程提供对 Qwen Coder + Vision 的 OAuth 访问。 -启用捆绑插件,然后登录: +内置提供商插件默认启用,只需登录: ```bash -openclaw plugins enable qwen-portal-auth openclaw models auth login --provider qwen-portal --set-default ``` @@ -198,21 +311,85 @@ openclaw models auth login --provider qwen-portal --set-default - `qwen-portal/coder-model` - `qwen-portal/vision-model` -参见 [/providers/qwen](/providers/qwen) 了解设置详情和注意事项。 +参阅 [/providers/qwen](/providers/qwen) 了解详情和注意事项。 -### Synthetic +### 火山引擎(豆包) -Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: +火山引擎提供对豆包及中国其他模型的访问。 -- 提供商:`synthetic` -- 认证:`SYNTHETIC_API_KEY` -- 示例模型:`synthetic/hf:MiniMaxAI/MiniMax-M2.1` -- CLI:`openclaw onboard --auth-choice synthetic-api-key` +- 提供商: `volcengine` (编码: `volcengine-plan`) +- 认证: `VOLCANO_ENGINE_API_KEY` +- 示例模型: `volcengine/doubao-seed-1-8-251228` +- CLI: `openclaw onboard --auth-choice volcengine-api-key` ```json5 { agents: { - defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }, + defaults: { model: { primary: "volcengine/doubao-seed-1-8-251228" } }, + }, +} +``` + +可用模型: + +- `volcengine/doubao-seed-1-8-251228` (豆包 Seed 1.8) +- `volcengine/doubao-seed-code-preview-251028` +- `volcengine/kimi-k2-5-260127` (Kimi K2.5) +- `volcengine/glm-4-7-251222` (GLM 4.7) +- `volcengine/deepseek-v3-2-251201` (DeepSeek V3.2 128K) + +编码模型(`volcengine-plan`): + +- `volcengine-plan/ark-code-latest` +- `volcengine-plan/doubao-seed-code` +- `volcengine-plan/kimi-k2.5` +- `volcengine-plan/kimi-k2-thinking` +- `volcengine-plan/glm-4.7` + +### BytePlus(国际版) + +BytePlus ARK 为国际用户提供与火山引擎相同的模型访问。 + +- 提供商: `byteplus` (编码: `byteplus-plan`) +- 认证: `BYTEPLUS_API_KEY` +- 示例模型: `byteplus/seed-1-8-251228` +- CLI: `openclaw onboard --auth-choice byteplus-api-key` + +```json5 +{ + agents: { + defaults: { model: { primary: "byteplus/seed-1-8-251228" } }, + }, +} +``` + +可用模型: + +- `byteplus/seed-1-8-251228` (Seed 1.8) +- `byteplus/kimi-k2-5-260127` (Kimi K2.5) +- `byteplus/glm-4-7-251222` (GLM 4.7) + +编码模型(`byteplus-plan`): + +- `byteplus-plan/ark-code-latest` +- `byteplus-plan/doubao-seed-code` +- `byteplus-plan/kimi-k2.5` +- `byteplus-plan/kimi-k2-thinking` +- `byteplus-plan/glm-4.7` + +### Synthetic + +Synthetic 提供 Anthropic 兼容模型,位于 `synthetic` 提供商背后: + +- 提供商: `synthetic` +- 认证: `SYNTHETIC_API_KEY` +- 示例模型: `synthetic/hf:MiniMaxAI/MiniMax-M2.5` +- CLI: `openclaw onboard --auth-choice synthetic-api-key` + +```json5 +{ + agents: { + defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } }, }, models: { mode: "merge", @@ -221,7 +398,7 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: baseUrl: "https://api.synthetic.new/anthropic", apiKey: "${SYNTHETIC_API_KEY}", api: "anthropic-messages", - models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }], + models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }], }, }, }, @@ -230,21 +407,21 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: ### MiniMax -MiniMax 通过 `models.providers` 配置,因为它使用自定义端点: +MiniMax 通过以下方式配置 `models.providers` ,因为它使用自定义端点: -- MiniMax(Anthropic 兼容):`--auth-choice minimax-api` -- 认证:`MINIMAX_API_KEY` +- MiniMax(Anthropic 兼容): `--auth-choice minimax-api` +- 认证: `MINIMAX_API_KEY` -参见 [/providers/minimax](/providers/minimax) 了解设置详情、模型选项和配置片段。 +参阅 [/providers/minimax](/providers/minimax) 了解详情、模型选项和配置代码片段。 ### Ollama -Ollama 是提供 OpenAI 兼容 API 的本地 LLM 运行时: +Ollama 作为内置提供商插件提供,并使用 Ollama 的原生 API: -- 提供商:`ollama` +- 提供商: `ollama` - 认证:无需(本地服务器) -- 示例模型:`ollama/llama3.3` -- 安装:https://ollama.ai +- 示例模型: `ollama/llama3.3` +- 安装: [https://ollama.com/download](https://ollama.com/download) ```bash # Install Ollama, then pull a model: @@ -259,18 +436,73 @@ ollama pull llama3.3 } ``` -当 Ollama 在本地 `http://127.0.0.1:11434/v1` 运行时会自动检测。参见 [/providers/ollama](/providers/ollama) 了解模型推荐和自定义配置。 +Ollama 在本地通过以下地址检测 `http://127.0.0.1:11434` 当你通过以下方式选择启用时 +`OLLAMA_API_KEY`,内置提供商插件会将 Ollama 直接添加到 +`openclaw onboard` 和模型选择器中。参阅 [/providers/ollama](/providers/ollama) +了解新手引导、云端/本地模式和自定义配置。 + +### vLLM + +vLLM 作为内置提供商插件提供,用于本地/自托管的兼容 OpenAI 服务器: + +- 提供商: `vllm` +- 认证:可选(取决于你的服务器) +- 默认基础 URL: `http://127.0.0.1:8000/v1` + +要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可): + +```bash +export VLLM_API_KEY="vllm-local" +``` + +然后设置一个模型(替换为由 `/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "vllm/your-model-id" } }, + }, +} +``` + +参阅 [/providers/vllm](/providers/vllm) 了解详情。 + +### SGLang + +SGLang 作为内置提供商插件提供,用于快速自托管的兼容 OpenAI 服务器: + +- 提供商: `sglang` +- 认证:可选(取决于你的服务器) +- 默认基础 URL: `http://127.0.0.1:30000/v1` + +要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可): + +```bash +export SGLANG_API_KEY="sglang-local" +``` + +然后设置一个模型(替换为由 `/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "sglang/your-model-id" } }, + }, +} +``` + +参阅 [/providers/sglang](/providers/sglang) 了解详情。 ### 本地代理(LM Studio、vLLM、LiteLLM 等) -示例(OpenAI 兼容): +示例(兼容 OpenAI): ```json5 { agents: { defaults: { - model: { primary: "lmstudio/minimax-m2.1-gs32" }, - models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } }, + model: { primary: "lmstudio/minimax-m2.5-gs32" }, + models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } }, }, }, models: { @@ -281,8 +513,8 @@ ollama pull llama3.3 api: "openai-completions", models: [ { - id: "minimax-m2.1-gs32", - name: "MiniMax M2.1", + id: "minimax-m2.5-gs32", + name: "MiniMax M2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -298,21 +530,24 @@ ollama pull llama3.3 注意事项: -- 对于自定义提供商,`reasoning`、`input`、`cost`、`contextWindow` 和 `maxTokens` 是可选的。 +- 对于自定义提供商, `reasoning`, `input`, `cost`, `contextWindow`,以及`maxTokens` 是可选的。 省略时,OpenClaw 默认为: - `reasoning: false` - `input: ["text"]` - `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }` - `contextWindow: 200000` - `maxTokens: 8192` -- 建议:设置与你的代理/模型限制匹配的显式值。 +- 建议:设置与你的代理/模型限制相匹配的显式值。 +- 对于 `api: "openai-completions"` 在非原生端点上(任何非空的 `baseUrl` 且主机不是 `api.openai.com`),OpenClaw 强制使用 `compat.supportsDeveloperRole: false` 以避免提供商对不支持的 `developer` 角色返回 400 错误。 +- 如果 `baseUrl` 为空/省略,OpenClaw 保持默认的 OpenAI 行为(解析为 `api.openai.com`)。 +- 为安全起见,显式的 `compat.supportsDeveloperRole: true` 在非原生 `openai-completions` 端点上仍会被覆盖。 ## CLI 示例 ```bash openclaw onboard --auth-choice opencode-zen -openclaw models set opencode/claude-opus-4-5 +openclaw models set opencode/claude-opus-4-6 openclaw models list ``` -另请参阅:[/gateway/configuration](/gateway/configuration) 了解完整配置示例。 +另请参阅: [/gateway/configuration](/gateway/configuration) 查看完整配置示例。 From acae0b60c2b1457bbba58a65da533915328d325c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:27:45 -0700 Subject: [PATCH 054/331] perf(plugins): lazy-load channel setup entrypoints --- docs/tools/plugin.md | 11 +-- extensions/discord/package.json | 3 +- extensions/discord/setup-entry.ts | 3 + extensions/imessage/package.json | 3 +- extensions/imessage/setup-entry.ts | 3 + extensions/signal/package.json | 3 +- extensions/signal/setup-entry.ts | 3 + extensions/slack/package.json | 3 +- extensions/slack/setup-entry.ts | 3 + extensions/telegram/package.json | 3 +- extensions/telegram/setup-entry.ts | 3 + extensions/whatsapp/package.json | 3 +- extensions/whatsapp/setup-entry.ts | 3 + src/commands/onboard-channels.ts | 59 +++++++++------- src/commands/onboarding/registry.ts | 74 ++++++++------------ src/plugins/loader.test.ts | 101 ++++++++++++++++++++++++++++ src/plugins/loader.ts | 33 ++++++++- src/plugins/registry.ts | 2 +- src/plugins/types.ts | 2 +- 19 files changed, 230 insertions(+), 88 deletions(-) create mode 100644 extensions/discord/setup-entry.ts create mode 100644 extensions/imessage/setup-entry.ts create mode 100644 extensions/signal/setup-entry.ts create mode 100644 extensions/slack/setup-entry.ts create mode 100644 extensions/telegram/setup-entry.ts create mode 100644 extensions/whatsapp/setup-entry.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 91613cbe731..3987ff6a7eb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -769,10 +769,11 @@ Security note: `openclaw plugins install` installs plugin dependencies with trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. -When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it -loads `setupEntry` instead of the full plugin entry. This keeps startup and -onboarding lighter when your main plugin entry also wires tools, hooks, or -other runtime-only code. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or +when a channel plugin is enabled but still unconfigured, it loads `setupEntry` +instead of the full plugin entry. This keeps startup and onboarding lighter +when your main plugin entry also wires tools, hooks, or other runtime-only +code. ### Channel catalog metadata @@ -1663,7 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. -- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/discord/package.json b/extensions/discord/package.json index a85eb37b85f..43e00315f28 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -6,6 +6,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts new file mode 100644 index 00000000000..56673347d64 --- /dev/null +++ b/extensions/discord/setup-entry.ts @@ -0,0 +1,3 @@ +import { discordPlugin } from "./src/channel.js"; + +export default { plugin: discordPlugin }; diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index c0988ee601c..591deea559b 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts new file mode 100644 index 00000000000..4b0cc6203e2 --- /dev/null +++ b/extensions/imessage/setup-entry.ts @@ -0,0 +1,3 @@ +import { imessagePlugin } from "./src/channel.js"; + +export default { plugin: imessagePlugin }; diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 67d6eae6506..f63128914c9 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts new file mode 100644 index 00000000000..afe80451845 --- /dev/null +++ b/extensions/signal/setup-entry.ts @@ -0,0 +1,3 @@ +import { signalPlugin } from "./src/channel.js"; + +export default { plugin: signalPlugin }; diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 183cdce7ad4..51439a37170 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts new file mode 100644 index 00000000000..d219e597148 --- /dev/null +++ b/extensions/slack/setup-entry.ts @@ -0,0 +1,3 @@ +import { slackPlugin } from "./src/channel.js"; + +export default { plugin: slackPlugin }; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 92054ca01a3..deed30477a9 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts new file mode 100644 index 00000000000..b5e7fc8c073 --- /dev/null +++ b/extensions/telegram/setup-entry.ts @@ -0,0 +1,3 @@ +import { telegramPlugin } from "./src/channel.js"; + +export default { plugin: telegramPlugin }; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index ec73a1b0613..356b2e3894b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts new file mode 100644 index 00000000000..0dd48c5b785 --- /dev/null +++ b/extensions/whatsapp/setup-entry.ts @@ -0,0 +1,3 @@ +import { whatsappPlugin } from "./src/channel.js"; + +export default { plugin: whatsappPlugin }; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cdb987914bc..cd269ac2cf9 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,6 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -28,8 +27,8 @@ import { loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; import { - getChannelOnboardingAdapter, - listChannelOnboardingAdapters, + loadBundledChannelOnboardingPlugin, + resolveChannelOnboardingAdapterForPlugin, } from "./onboarding/registry.js"; import type { ChannelOnboardingAdapter, @@ -121,7 +120,8 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ReturnType; + installedPlugins?: ChannelPlugin[]; + resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); @@ -134,14 +134,24 @@ async function collectChannelStatus(params: { }).plugins.flatMap((plugin) => plugin.channels), ); const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const resolveAdapter = + params.resolveAdapter ?? + ((channel: ChannelChoice) => + resolveChannelOnboardingAdapterForPlugin( + installedPlugins.find((plugin) => plugin.id === channel), + )); const statusEntries = await Promise.all( - listChannelOnboardingAdapters().map((adapter) => - adapter.getStatus({ + installedPlugins.flatMap((plugin) => { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return []; + } + return adapter.getStatus({ cfg: params.cfg, options: params.options, accountOverrides: params.accountOverrides, - }), - ), + }); + }), ); const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry])); const fallbackStatuses = listChatChannels() @@ -270,7 +280,7 @@ async function maybeConfigureDmPolicies(params: { resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter; + const resolve = params.resolveAdapter; const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; @@ -362,10 +372,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const loadScopedChannelPlugin = ( + const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): ChannelPlugin | undefined => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; @@ -382,22 +392,20 @@ export async function setupChannels( snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); + return plugin; } - return plugin; + const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); + if (bundledPlugin) { + rememberScopedPlugin(bundledPlugin); + } + return bundledPlugin; }; const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); - if (adapter) { - return adapter; - } const scopedPlugin = scopedPluginsById.get(channel); - if (!scopedPlugin?.setupWizard) { - return undefined; + if (scopedPlugin) { + return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); } - return buildChannelOnboardingAdapterFromSetupWizard({ - plugin: scopedPlugin, - wizard: scopedPlugin.setupWizard, - }); + return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { // Keep onboarding memory bounded by snapshot-loading only configured external plugins. @@ -412,7 +420,7 @@ export async function setupChannels( if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { continue; } - loadScopedChannelPlugin(channel, entry.pluginId); + void loadScopedChannelPlugin(channel, entry.pluginId); } }; if (options?.whatsappAccountId?.trim()) { @@ -426,6 +434,7 @@ export async function setupChannels( options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleOnboardingAdapter, }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); @@ -586,8 +595,8 @@ export async function setupChannels( ); return false; } + const plugin = await loadScopedChannelPlugin(channel); const adapter = getVisibleOnboardingAdapter(channel); - const plugin = loadScopedChannelPlugin(channel); if (!plugin) { if (adapter) { await prompter.note( @@ -752,7 +761,7 @@ export async function setupChannels( if (!result.installed) { return; } - loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); + await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 99009ee8fac..01bc0deeb7a 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,54 +1,15 @@ -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: telegramPlugin, - wizard: telegramPlugin.setupWizard!, -}); -const discordOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: discordPlugin, - wizard: discordPlugin.setupWizard!, -}); -const slackOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: slackPlugin, - wizard: slackPlugin.setupWizard!, -}); -const signalOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: signalPlugin, - wizard: signalPlugin.setupWizard!, -}); -const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: imessagePlugin, - wizard: imessagePlugin.setupWizard!, -}); -const whatsappOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: whatsappPlugin, - wizard: whatsappPlugin.setupWizard!, -}); - -const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ - telegramOnboardingAdapter, - whatsappOnboardingAdapter, - discordOnboardingAdapter, - slackOnboardingAdapter, - signalOnboardingAdapter, - imessageOnboardingAdapter, -]; - const setupWizardAdapters = new WeakMap(); -function resolveChannelOnboardingAdapter( - plugin: ReturnType[number], +export function resolveChannelOnboardingAdapterForPlugin( + plugin?: ChannelPlugin, ): ChannelOnboardingAdapter | undefined { - if (plugin.setupWizard) { + if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); if (cached) { return cached; @@ -64,11 +25,9 @@ function resolveChannelOnboardingAdapter( } const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map( - BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const), - ); + const adapters = new Map(); for (const plugin of listChannelSetupPlugins()) { - const adapter = resolveChannelOnboardingAdapter(plugin); + const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; } @@ -87,6 +46,27 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); } +export async function loadBundledChannelOnboardingPlugin( + channel: ChannelChoice, +): Promise { + switch (channel) { + case "discord": + return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; + case "imessage": + return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; + case "signal": + return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; + case "slack": + return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; + case "telegram": + return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; + case "whatsapp": + return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; + default: + return undefined; + } +} + // Legacy aliases (pre-rename). export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index fb6805667cb..45710ef08bf 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1885,6 +1885,107 @@ module.exports = { expect(setupRegistry.channels).toHaveLength(0); }); + it("uses package setupEntry for enabled but unconfigured channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "full entry should not run while unconfigured", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "setup runtime", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-test"], + }, + }, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 40fd3e36cfd..a58d0a640a2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; @@ -357,6 +358,20 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { }; } +function shouldLoadChannelPluginInSetupRuntime(params: { + manifestChannels: string[]; + setupSource?: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): boolean { + if (!params.setupSource || params.manifestChannels.length === 0) { + return false; + } + return !params.manifestChannels.some((channelId) => + isChannelConfigured(params.cfg, channelId, params.env), + ); +} + function createPluginRecord(params: { id: string; name?: string; @@ -924,7 +939,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }; const registrationMode = enableState.enabled - ? "full" + ? !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + cfg, + env, + }) + ? "setup-runtime" + : "full" : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -994,7 +1017,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const pluginRoot = safeRealpathOrResolve(candidate.rootDir); const loadSource = - registrationMode === "setup-only" && manifestRecord.setupSource + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource ? manifestRecord.setupSource : candidate.source; const opened = openBoundaryFileSync({ @@ -1029,7 +1053,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (registrationMode === "setup-only" && manifestRecord.setupSource) { + if ( + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource + ) { const setupRegistration = resolveSetupChannelRegistration(mod); if (setupRegistration.plugin) { if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 42e9c236909..9b450af26e7 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -481,7 +481,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); - if (mode === "full" && existingRuntime) { + if (mode !== "setup-only" && existingRuntime) { pushDiagnostic({ level: "error", pluginId: record.id, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 9ad44fff40d..3b133642313 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -956,7 +956,7 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); -export type PluginRegistrationMode = "full" | "setup-only"; +export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime"; export type OpenClawPluginApi = { id: string; From ecc688d20552f11a8e8f17aa48a1382133db346d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:29:08 -0700 Subject: [PATCH 055/331] Google Chat: split setup adapter helpers --- extensions/googlechat/src/channel.ts | 3 +- extensions/googlechat/src/setup-core.ts | 67 ++++++++++++++++++++++ extensions/googlechat/src/setup-surface.ts | 63 +------------------- src/plugin-sdk/googlechat.ts | 6 +- 4 files changed, 74 insertions(+), 65 deletions(-) create mode 100644 extensions/googlechat/src/setup-core.ts diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 9ea172091f1..5d2c9d86748 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -35,7 +35,8 @@ import { } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; import { getGoogleChatRuntime } from "./runtime.js"; -import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js"; +import { googlechatSetupAdapter } from "./setup-core.js"; +import { googlechatSetupWizard } from "./setup-surface.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts new file mode 100644 index 00000000000..d4d2de49e06 --- /dev/null +++ b/extensions/googlechat/src/setup-core.ts @@ -0,0 +1,67 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "googlechat" as const; + +export const googlechatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Google Chat requires --token (service account JSON) or --token-file."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { serviceAccountFile: input.tokenFile } + : input.token + ? { serviceAccount: input.token } + : {}; + const audienceType = input.audienceType?.trim(); + const audience = input.audience?.trim(); + const webhookPath = input.webhookPath?.trim(); + const webhookUrl = input.webhookUrl?.trim(); + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }, + }); + }, +}; diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index e812561f674..64fe7837fa3 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -6,21 +6,20 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import { - applyAccountNameToChannelSection, applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount, } from "./accounts.js"; +import { googlechatSetupAdapter } from "./setup-core.js"; const channel = "googlechat" as const; const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; @@ -87,63 +86,7 @@ const googlechatDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom, }; -export const googlechatSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Google Chat requires --token (service account JSON) or --token-file."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { serviceAccountFile: input.tokenFile } - : input.token - ? { serviceAccount: input.token } - : {}; - const audienceType = input.audienceType?.trim(); - const audience = input.audience?.trim(); - const webhookPath = input.webhookPath?.trim(); - const webhookUrl = input.webhookUrl?.trim(); - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: { - ...patch, - ...(audienceType ? { audienceType } : {}), - ...(audience ? { audience } : {}), - ...(webhookPath ? { webhookPath } : {}), - ...(webhookUrl ? { webhookUrl } : {}), - }, - }); - }, -}; +export { googlechatSetupAdapter } from "./setup-core.js"; export const googlechatSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index e6e9aaefb1c..464af58776b 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -67,10 +67,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - googlechatSetupAdapter, - googlechatSetupWizard, -} from "../../extensions/googlechat/src/setup-surface.js"; +export { googlechatSetupAdapter } from "../../extensions/googlechat/src/setup-core.js"; +export { googlechatSetupWizard } from "../../extensions/googlechat/src/setup-surface.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; From 7212b5f01a1056efd94b0d53ba45dda9ebc24650 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:31:11 -0700 Subject: [PATCH 056/331] Matrix: split setup adapter helpers --- extensions/matrix/src/channel.ts | 3 +- extensions/matrix/src/setup-core.ts | 111 ++++++++++++++++++++++++ extensions/matrix/src/setup-surface.ts | 114 +------------------------ src/plugin-sdk/matrix.ts | 6 +- 4 files changed, 119 insertions(+), 115 deletions(-) create mode 100644 extensions/matrix/src/setup-core.ts diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 8e3c858ecde..5d6f2a9d9b2 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -29,7 +29,8 @@ import { } from "./matrix/accounts.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import { getMatrixRuntime } from "./runtime.js"; -import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js"; +import { matrixSetupAdapter } from "./setup-core.js"; +import { matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts new file mode 100644 index 00000000000..f0fc395a344 --- /dev/null +++ b/extensions/matrix/src/setup-core.ts @@ -0,0 +1,111 @@ +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +export function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + +export const matrixSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = normalizeSecretInputString(input.password); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (input.useEnv) { + return { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(next as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: normalizeSecretInputString(input.password), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, +}; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 9f37f000c46..e01e0d57750 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -7,63 +7,24 @@ import { promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; +import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - }, -): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; -} - function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; @@ -220,74 +181,7 @@ const matrixDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; -export const matrixSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (input.useEnv) { - return { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(next as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, - }); - }, -}; +export { matrixSetupAdapter } from "./setup-core.js"; export const matrixSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 52d18e4665f..8a62aa9ae10 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -108,7 +108,5 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { - matrixSetupAdapter, - matrixSetupWizard, -} from "../../extensions/matrix/src/setup-surface.js"; +export { matrixSetupWizard } from "../../extensions/matrix/src/setup-surface.js"; +export { matrixSetupAdapter } from "../../extensions/matrix/src/setup-core.js"; From 0c9428a865899f7c6abe45af0131bdebf6d1d10b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:32:48 -0700 Subject: [PATCH 057/331] MSTeams: split setup adapter helpers --- extensions/msteams/src/channel.ts | 3 ++- extensions/msteams/src/setup-core.ts | 16 ++++++++++++++++ extensions/msteams/src/setup-surface.ts | 16 ++-------------- src/plugin-sdk/msteams.ts | 6 ++---- 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 extensions/msteams/src/setup-core.ts diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a4e62e5e310..f87f239166c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -26,7 +26,8 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; -import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js"; +import { msteamsSetupAdapter } from "./setup-core.js"; +import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { diff --git a/extensions/msteams/src/setup-core.ts b/extensions/msteams/src/setup-core.ts new file mode 100644 index 00000000000..74079aaf389 --- /dev/null +++ b/extensions/msteams/src/setup-core.ts @@ -0,0 +1,16 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + +export const msteamsSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), +}; diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 8d5ebdbb5ef..f8db90e5079 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -8,7 +8,6 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -20,6 +19,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { normalizeSecretInputString } from "./secret-input.js"; +import { msteamsSetupAdapter } from "./setup-core.js"; import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; const channel = "msteams" as const; @@ -201,19 +201,7 @@ const msteamsDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMSTeamsAllowFrom, }; -export const msteamsSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled: true, - }, - }, - }), -}; +export { msteamsSetupAdapter } from "./setup-core.js"; export const msteamsSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index d99f703ed64..2f5a91d8989 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -117,7 +117,5 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { - msteamsSetupAdapter, - msteamsSetupWizard, -} from "../../extensions/msteams/src/setup-surface.js"; +export { msteamsSetupWizard } from "../../extensions/msteams/src/setup-surface.js"; +export { msteamsSetupAdapter } from "../../extensions/msteams/src/setup-core.js"; From a516141bdae5f3a4136f10ce1491452aa0e77f8e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 07:49:13 +0530 Subject: [PATCH 058/331] feat(telegram): add topic-edit action --- extensions/telegram/src/channel-actions.ts | 27 +++++++++ extensions/telegram/src/send.test.ts | 37 ++++++++++++ extensions/telegram/src/send.ts | 63 ++++++++++++++++---- src/agents/tools/telegram-actions.test.ts | 17 ++++++ src/agents/tools/telegram-actions.ts | 32 ++++++++++ src/channels/plugins/actions/actions.test.ts | 33 ++++++++++ src/channels/plugins/message-action-names.ts | 1 + src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/infra/outbound/message-action-spec.ts | 1 + 10 files changed, 204 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 29095e7bc7c..1745071c060 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -115,6 +115,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (isEnabled("createForumTopic")) { actions.add("topic-create"); } + if (isEnabled("editForumTopic")) { + actions.add("topic-edit"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -290,6 +293,30 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "topic-edit") { + const chatId = readTelegramChatIdParam(params); + const messageThreadId = + readNumberParam(params, "messageThreadId", { integer: true }) ?? + readNumberParam(params, "threadId", { integer: true }); + if (typeof messageThreadId !== "number") { + throw new Error("messageThreadId or threadId is required."); + } + const name = readStringParam(params, "name"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "editForumTopic", + chatId, + messageThreadId, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 8a234ce92cb..ba1863b1b90 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -15,6 +15,7 @@ const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegr const { buildInlineKeyboard, createForumTopicTelegram, + editForumTopicTelegram, editMessageTelegram, pinMessageTelegram, reactMessageTelegram, @@ -257,6 +258,42 @@ describe("sendMessageTelegram", () => { }); }); + it("edits a Telegram forum topic name and icon via the shared helper", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + name: "Codex Thread", + iconCustomEmojiId: "emoji-123", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + icon_custom_emoji_id: "emoji-123", + }); + }); + + it("rejects empty topic edits", async () => { + await expect( + editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + }), + ).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId"); + await expect( + editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + iconCustomEmojiId: " ", + }), + ).rejects.toThrow("Telegram forum topic icon custom emoji ID is required"); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 89d6f7d337d..d96e783c51d 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1128,19 +1128,39 @@ export async function unpinMessageTelegram( }; } -export async function renameForumTopicTelegram( +type TelegramEditForumTopicOpts = TelegramDeleteOpts & { + name?: string; + iconCustomEmojiId?: string; +}; + +export async function editForumTopicTelegram( chatIdInput: string | number, messageThreadIdInput: string | number, - name: string, - opts: TelegramDeleteOpts = {}, -): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { - const trimmedName = name.trim(); - if (!trimmedName) { + opts: TelegramEditForumTopicOpts = {}, +): Promise<{ + ok: true; + chatId: string; + messageThreadId: number; + name?: string; + iconCustomEmojiId?: string; +}> { + const nameProvided = opts.name !== undefined; + const trimmedName = opts.name?.trim(); + if (nameProvided && !trimmedName) { throw new Error("Telegram forum topic name is required"); } - if (trimmedName.length > 128) { + if (trimmedName && trimmedName.length > 128) { throw new Error("Telegram forum topic name must be 128 characters or fewer"); } + const iconProvided = opts.iconCustomEmojiId !== undefined; + const trimmedIconCustomEmojiId = opts.iconCustomEmojiId?.trim(); + if (iconProvided && !trimmedIconCustomEmojiId) { + throw new Error("Telegram forum topic icon custom emoji ID is required"); + } + if (!trimmedName && !trimmedIconCustomEmojiId) { + throw new Error("Telegram forum topic update requires a name or iconCustomEmojiId"); + } + const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ @@ -1157,16 +1177,39 @@ export async function renameForumTopicTelegram( retry: opts.retry, verbose: opts.verbose, }); + const payload = { + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedIconCustomEmojiId ? { icon_custom_emoji_id: trimmedIconCustomEmojiId } : {}), + }; await requestWithDiag( - () => api.editForumTopic(chatId, messageThreadId, { name: trimmedName }), + () => api.editForumTopic(chatId, messageThreadId, payload), "editForumTopic", ); - logVerbose(`[telegram] Renamed forum topic ${messageThreadId} in chat ${chatId}`); + logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`); return { ok: true, chatId, messageThreadId, - name: trimmedName, + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedIconCustomEmojiId ? { iconCustomEmojiId: trimmedIconCustomEmojiId } : {}), + }; +} + +export async function renameForumTopicTelegram( + chatIdInput: string | number, + messageThreadIdInput: string | number, + name: string, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { + const result = await editForumTopicTelegram(chatIdInput, messageThreadIdInput, { + ...opts, + name, + }); + return { + ok: true, + chatId: result.chatId, + messageThreadId: result.messageThreadId, + name: result.name ?? name.trim(), }; } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 5963a64b667..997de707765 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -23,6 +23,12 @@ const editMessageTelegram = vi.fn(async () => ({ messageId: "456", chatId: "123", })); +const editForumTopicTelegram = vi.fn(async () => ({ + ok: true, + chatId: "123", + messageThreadId: 42, + name: "Renamed", +})); const createForumTopicTelegram = vi.fn(async () => ({ topicId: 99, name: "Topic", @@ -42,6 +48,8 @@ vi.mock("../../../extensions/telegram/src/send.js", () => ({ deleteMessageTelegram(...args), editMessageTelegram: (...args: Parameters) => editMessageTelegram(...args), + editForumTopicTelegram: (...args: Parameters) => + editForumTopicTelegram(...args), createForumTopicTelegram: (...args: Parameters) => createForumTopicTelegram(...args), })); @@ -105,6 +113,7 @@ describe("handleTelegramAction", () => { sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); editMessageTelegram.mockClear(); + editForumTopicTelegram.mockClear(); createForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -457,6 +466,14 @@ describe("handleTelegramAction", () => { readCallOpts: (calls: unknown[][], argIndex: number) => Record, ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), }, + { + name: "editForumTopic", + params: { action: "editForumTopic", chatId: "123", messageThreadId: 42, name: "New" }, + cfg: telegramConfig({ actions: { editForumTopic: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(editForumTopicTelegram.mock.calls as unknown[][], 2), + }, ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { const readCallOpts = (calls: unknown[][], argIndex: number): Record => { const args = calls[0]; diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 6c8d4f84204..ccfc9d5ae13 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -15,6 +15,7 @@ import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/r import { createForumTopicTelegram, deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, @@ -478,5 +479,36 @@ export async function handleTelegramAction( }); } + if (action === "editForumTopic") { + if (!isActionEnabled("editForumTopic")) { + throw new Error("Telegram editForumTopic is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageThreadId = + readNumberParam(params, "messageThreadId", { integer: true }) ?? + readNumberParam(params, "threadId", { integer: true }); + if (typeof messageThreadId !== "number") { + throw new Error("messageThreadId or threadId is required."); + } + const name = readStringParam(params, "name"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, { + cfg, + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }); + return jsonResult(result); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 055d660524f..bf75f9997d2 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -540,6 +540,21 @@ describe("telegramMessageActions", () => { expect(actions).toContain("poll"); }); + it("lists topic-edit when telegram topic edits are enabled", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + actions: { editForumTopic: true }, + }, + }, + } as OpenClawConfig; + + const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).toContain("topic-edit"); + }); + it("omits poll when sendMessage is disabled", () => { const cfg = { channels: { @@ -793,6 +808,24 @@ describe("telegramMessageActions", () => { accountId: undefined, }, }, + { + name: "topic-edit maps to editForumTopic", + action: "topic-edit" as const, + params: { + to: "telegram:group:-1001234567890:topic:271", + threadId: 271, + name: "Build Updates", + iconCustomEmojiId: "emoji-123", + }, + expectedPayload: { + action: "editForumTopic", + chatId: "telegram:group:-1001234567890:topic:271", + messageThreadId: 271, + name: "Build Updates", + iconCustomEmojiId: "emoji-123", + accountId: undefined, + }, + }, ] as const; for (const testCase of cases) { diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 809d239be2c..aadff95c77d 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -44,6 +44,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "category-edit", "category-delete", "topic-create", + "topic-edit", "voice-status", "event-list", "event-create", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 252f66740b2..fe1c5be3962 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -26,6 +26,8 @@ export type TelegramActionConfig = { sticker?: boolean; /** Enable forum topic creation. */ createForumTopic?: boolean; + /** Enable forum topic editing (rename / change icon). */ + editForumTopic?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 5f7dd7b8e48..da81ef61a4f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -258,6 +258,7 @@ export const TelegramAccountSchemaBase = z editMessage: z.boolean().optional(), sticker: z.boolean().optional(), createForumTopic: z.boolean().optional(), + editForumTopic: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index b49a60c6991..f4f715d869d 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -49,6 +49,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record Date: Mon, 16 Mar 2026 07:58:33 +0530 Subject: [PATCH 059/331] fix(telegram): normalize topic-edit targets --- extensions/telegram/src/send.test.ts | 20 ++++++++++++++++++++ extensions/telegram/src/send.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ba1863b1b90..78804cac8a8 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -280,6 +280,26 @@ describe("sendMessageTelegram", () => { }); }); + it("strips topic suffixes before editing a Telegram forum topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await editForumTopicTelegram("telegram:group:-1001234567890:topic:271", 271, { + accountId: "default", + name: "Codex Thread", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + }); + }); + it("rejects empty topic edits", async () => { await expect( editForumTopicTelegram("-1001234567890", 271, { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index d96e783c51d..b215be835e8 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1163,10 +1163,11 @@ export async function editForumTopicTelegram( const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); + const target = parseTelegramTarget(rawTarget); const chatId = await resolveAndPersistChatId({ cfg, api, - lookupTarget: rawTarget, + lookupTarget: target.chatId, persistTarget: rawTarget, verbose: opts.verbose, }); From c08796b0394c2767d6c25e6208a8beee40752c40 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 08:01:26 +0530 Subject: [PATCH 060/331] fix: add Telegram topic-edit action (#47798) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d0b32ae92..f46e450d164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. +- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. ### Fixes From 61bcdcca9c43c6e00ca688f436c53a62f7c4bd06 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:34:58 -0700 Subject: [PATCH 061/331] Feishu: split setup adapter helpers --- extensions/feishu/src/channel.ts | 3 +- extensions/feishu/src/setup-core.ts | 48 ++++++++++++++++++++++++++ extensions/feishu/src/setup-surface.ts | 46 ++---------------------- src/plugin-sdk/feishu.ts | 6 ++-- 4 files changed, 54 insertions(+), 49 deletions(-) create mode 100644 extensions/feishu/src/setup-core.ts diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 7d8560d5182..034b9b7c6a1 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,7 +25,8 @@ import { FeishuConfigSchema } from "./config-schema.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; -import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js"; +import { feishuSetupAdapter } from "./setup-core.js"; +import { feishuSetupWizard } from "./setup-surface.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; diff --git a/extensions/feishu/src/setup-core.ts b/extensions/feishu/src/setup-core.ts new file mode 100644 index 00000000000..ada8ef79933 --- /dev/null +++ b/extensions/feishu/src/setup-core.ts @@ -0,0 +1,48 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { FeishuConfig } from "./types.js"; + +export function setFeishuNamedAccountEnabled( + cfg: OpenClawConfig, + accountId: string, + enabled: boolean, +): OpenClawConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; +} + +export const feishuSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; + } + return setFeishuNamedAccountEnabled(cfg, accountId, true); + }, +}; diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 1191a08e4e9..567ccea1a7e 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -9,7 +9,6 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; @@ -18,6 +17,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; +import { feishuSetupAdapter } from "./setup-core.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; @@ -30,30 +30,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuNamedAccountEnabled( - cfg: OpenClawConfig, - accountId: string, - enabled: boolean, -): OpenClawConfig { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...feishuCfg, - accounts: { - ...feishuCfg?.accounts, - [accountId]: { - ...feishuCfg?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; -} - function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, @@ -211,25 +187,7 @@ const feishuDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptFeishuAllowFrom, }; -export const feishuSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg, accountId }) => { - const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - }, - }, - }; - } - return setFeishuNamedAccountEnabled(cfg, accountId, true); - }, -}; +export { feishuSetupAdapter } from "./setup-core.js"; export const feishuSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 65f0773105b..246185f404e 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -62,10 +62,8 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - feishuSetupAdapter, - feishuSetupWizard, -} from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupWizard } from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupAdapter } from "../../extensions/feishu/src/setup-core.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; From b37085984d56110b01fcfdb2120e1cf1f056155e Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:34:56 -0500 Subject: [PATCH 062/331] fixed main? --- extensions/zalouser/src/zca-client.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index 00a1c8c1be0..f7bc1a358b3 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -1,16 +1,18 @@ -import { - LoginQRCallbackEventType as LoginQRCallbackEventTypeRuntime, - Reactions as ReactionsRuntime, - ThreadType as ThreadTypeRuntime, - Zalo as ZaloRuntime, -} from "zca-js"; +import * as zcaJsRuntime from "zca-js"; -export const ThreadType = ThreadTypeRuntime as { +const zcaJs = zcaJsRuntime as unknown as { + ThreadType: unknown; + LoginQRCallbackEventType: unknown; + Reactions: unknown; + Zalo: unknown; +}; + +export const ThreadType = zcaJs.ThreadType as { User: 0; Group: 1; }; -export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { +export const LoginQRCallbackEventType = zcaJs.LoginQRCallbackEventType as { QRCodeGenerated: 0; QRCodeExpired: 1; QRCodeScanned: 2; @@ -18,7 +20,7 @@ export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { GotLoginInfo: 4; }; -export const Reactions = ReactionsRuntime as Record & { +export const Reactions = zcaJs.Reactions as Record & { HEART: string; LIKE: string; HAHA: string; @@ -290,4 +292,4 @@ type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => { ): Promise; }; -export const Zalo = ZaloRuntime as unknown as ZaloCtor; +export const Zalo = zcaJs.Zalo as unknown as ZaloCtor; From 88b8151c524b4f0701fd0546c81a5e0707db81d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:37:12 -0700 Subject: [PATCH 063/331] Zalo: split setup adapter helpers --- extensions/zalo/src/channel.ts | 3 +- extensions/zalo/src/setup-core.ts | 57 ++++++++++++++++++++++++++++ extensions/zalo/src/setup-surface.ts | 55 +-------------------------- src/plugin-sdk/zalo.ts | 3 +- 4 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 extensions/zalo/src/setup-core.ts diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index adba1f8bd93..69f99c69e3a 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -40,7 +40,8 @@ import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; -import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; +import { zaloSetupAdapter } from "./setup-core.js"; +import { zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts new file mode 100644 index 00000000000..6e194a41652 --- /dev/null +++ b/extensions/zalo/src/setup-core.ts @@ -0,0 +1,57 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalo" as const; + +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 643c2f6ff76..125bc322998 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -6,19 +6,14 @@ import { runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; +import { zaloSetupAdapter } from "./setup-core.js"; const channel = "zalo" as const; @@ -207,53 +202,7 @@ const zaloDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zaloSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch, - }); - }, -}; +export { zaloSetupAdapter } from "./setup-core.js"; export const zaloSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 4323ae4eb6e..307ea5f16f5 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -64,7 +64,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; -export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; +export { zaloSetupAdapter } from "../../extensions/zalo/src/setup-core.js"; +export { zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, From b580d142cd56738c3f62f1152765b9e09b312691 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:38:13 -0700 Subject: [PATCH 064/331] refactor(plugins): split lightweight channel setup modules --- extensions/discord/setup-entry.ts | 4 +- extensions/discord/src/channel.setup.ts | 75 +++++++++ extensions/imessage/setup-entry.ts | 4 +- extensions/imessage/src/channel.setup.ts | 99 ++++++++++++ extensions/signal/setup-entry.ts | 4 +- extensions/signal/src/channel.setup.ts | 112 +++++++++++++ extensions/slack/setup-entry.ts | 4 +- extensions/slack/src/channel.setup.ts | 100 ++++++++++++ extensions/telegram/setup-entry.ts | 4 +- extensions/telegram/src/channel.setup.ts | 125 ++++++++++++++ extensions/whatsapp/setup-entry.ts | 4 +- extensions/whatsapp/src/channel.setup.ts | 198 +++++++++++++++++++++++ 12 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 extensions/discord/src/channel.setup.ts create mode 100644 extensions/imessage/src/channel.setup.ts create mode 100644 extensions/signal/src/channel.setup.ts create mode 100644 extensions/slack/src/channel.setup.ts create mode 100644 extensions/telegram/src/channel.setup.ts create mode 100644 extensions/whatsapp/src/channel.setup.ts diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index 56673347d64..329a9376c9f 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -1,3 +1,3 @@ -import { discordPlugin } from "./src/channel.js"; +import { discordSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: discordPlugin }; +export default { plugin: discordSetupPlugin }; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts new file mode 100644 index 00000000000..ac79acf443e --- /dev/null +++ b/extensions/discord/src/channel.setup.ts @@ -0,0 +1,75 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + inspectDiscordAccount, + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ChannelPlugin, + type ResolvedDiscordAccount, +} from "openclaw/plugin-sdk/discord"; +import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + +export const discordSetupPlugin: ChannelPlugin = { + id: "discord", + meta: { + ...getChatChannelMeta("discord"), + }, + setupWizard: discordSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, + setup: discordSetupAdapter, +}; diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index 4b0cc6203e2..6b4c642d0ae 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -1,3 +1,3 @@ -import { imessagePlugin } from "./src/channel.js"; +import { imessageSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: imessagePlugin }; +export default { plugin: imessageSetupPlugin }; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts new file mode 100644 index 00000000000..075e50f0dda --- /dev/null +++ b/extensions/imessage/src/channel.setup.ts @@ -0,0 +1,99 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type ResolvedIMessageAccount, +} from "openclaw/plugin-sdk/imessage"; +import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + +export const imessageSetupPlugin: ChannelPlugin = { + id: "imessage", + meta: { + ...getChatChannelMeta("imessage"), + aliases: ["imsg"], + showConfigured: false, + }, + setupWizard: imessageSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "imessage", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }), + }, + setup: imessageSetupAdapter, +}; diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index afe80451845..18c27ec5a16 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,3 +1,3 @@ -import { signalPlugin } from "./src/channel.js"; +import { signalSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: signalPlugin }; +export default { plugin: signalSetupPlugin }; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts new file mode 100644 index 00000000000..544efa0f64f --- /dev/null +++ b/extensions/signal/src/channel.setup.ts @@ -0,0 +1,112 @@ +import { + createScopedAccountConfigAccessors, + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + listSignalAccountIds, + normalizeE164, + resolveDefaultSignalAccountId, + resolveSignalAccount, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, + type ResolvedSignalAccount, +} from "openclaw/plugin-sdk/signal"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); + +export const signalSetupPlugin: ChannelPlugin = { + id: "signal", + meta: { + ...getChatChannelMeta("signal"), + }, + setupWizard: signalSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "signal", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "signal", + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "signal", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, + setup: signalSetupAdapter, +}; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index d219e597148..1bd6eabde59 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,3 +1,3 @@ -import { slackPlugin } from "./src/channel.js"; +import { slackSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: slackPlugin }; +export default { plugin: slackSetupPlugin }; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts new file mode 100644 index 00000000000..2f7b888ca18 --- /dev/null +++ b/extensions/slack/src/channel.setup.ts @@ -0,0 +1,100 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectSlackAccount, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + SlackConfigSchema, + type ChannelPlugin, + type ResolvedSlackAccount, +} from "openclaw/plugin-sdk/slack"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + +export const slackSetupPlugin: ChannelPlugin = { + id: "slack", + meta: { + ...getChatChannelMeta("slack"), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: slackSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + cfg.channels?.slack?.accounts?.[accountId ?? "default"]?.capabilities?.interactiveReplies === + true || cfg.channels?.slack?.capabilities?.interactiveReplies === true + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: slackSetupAdapter, +}; diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index b5e7fc8c073..030f4bb3295 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -1,3 +1,3 @@ -import { telegramPlugin } from "./src/channel.js"; +import { telegramSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: telegramPlugin }; +export default { plugin: telegramSetupPlugin }; diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts new file mode 100644 index 00000000000..6abc8ba0c62 --- /dev/null +++ b/extensions/telegram/src/channel.setup.ts @@ -0,0 +1,125 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectTelegramAccount, + listTelegramAccountIds, + normalizeAccountId, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, + type ResolvedTelegramAccount, + type TelegramProbe, +} from "openclaw/plugin-sdk/telegram"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; + +function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +export const telegramSetupPlugin: ChannelPlugin = { + id: "telegram", + meta: { + ...getChatChannelMeta("telegram"), + quickstartAllowFrom: true, + }, + setupWizard: telegramSetupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: telegramSetupAdapter, +}; diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index 0dd48c5b785..5b18e10073b 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -1,3 +1,3 @@ -import { whatsappPlugin } from "./src/channel.js"; +import { whatsappSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: whatsappPlugin }; +export default { plugin: whatsappSetupPlugin }; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts new file mode 100644 index 00000000000..b352bd2ed73 --- /dev/null +++ b/extensions/whatsapp/src/channel.setup.ts @@ -0,0 +1,198 @@ +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; +import { webAuthExists } from "./auth-store.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; + +export const whatsappSetupPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + ...getChatChannelMeta("whatsapp"), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: whatsappSetupWizardProxy, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: async (account) => await webAuthExists(account.authDir), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "whatsapp", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: whatsappSetupAdapter, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, +}; From dd203c8eee1bf724656ba9a3d45c85176d0bd5f9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:39:20 -0700 Subject: [PATCH 065/331] Zalouser: split setup adapter helpers --- extensions/zalouser/src/channel.ts | 3 +- extensions/zalouser/src/setup-core.ts | 42 ++++++++++++++++++++++++ extensions/zalouser/src/setup-surface.ts | 42 ++---------------------- src/plugin-sdk/zalouser.ts | 6 ++-- 4 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 extensions/zalouser/src/setup-core.ts diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index b7d103e9b6e..46dbb2c9fee 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -42,7 +42,8 @@ import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; -import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts new file mode 100644 index 00000000000..45f412ed9f6 --- /dev/null +++ b/extensions/zalouser/src/setup-core.ts @@ -0,0 +1,42 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalouser" as const; + +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index b091ed37947..3ce0bd9d066 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -3,14 +3,8 @@ import { mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; +import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -22,6 +16,7 @@ import { checkZcaAuthenticated, } from "./accounts.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; import { logoutZaloProfile, resolveZaloAllowFromEntries, @@ -169,38 +164,7 @@ const zalouserDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zalouserSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: {}, - }); - }, -}; +export { zalouserSetupAdapter } from "./setup-core.js"; export const zalouserSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 47fc787570c..3ad3ca47549 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -55,10 +55,8 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { - zalouserSetupAdapter, - zalouserSetupWizard, -} from "../../extensions/zalouser/src/setup-surface.js"; +export { zalouserSetupAdapter } from "../../extensions/zalouser/src/setup-core.js"; +export { zalouserSetupWizard } from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, From fdfefcaa118168fede051de6ccf1f8f4ee5e919b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:43:42 -0700 Subject: [PATCH 066/331] Status: skip unused channel issue scan in JSON mode --- src/commands/status.scan.test.ts | 6 +++++- src/commands/status.scan.ts | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 9d3399997bf..b94f1f0ece0 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), buildChannelsTable: vi.fn(), + callGateway: vi.fn(), getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), @@ -51,7 +52,7 @@ vi.mock("../infra/tailscale.js", () => ({ vi.mock("../gateway/call.js", () => ({ buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, - callGateway: vi.fn(), + callGateway: mocks.callGateway, })); vi.mock("../gateway/probe.js", () => ({ @@ -245,6 +246,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.callGateway).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "channels.status" }), + ); }); it("preloads channel plugins for status --json when channel auth is env-only", async () => { diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 0de308f17f2..8de4aae7745 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -247,11 +247,9 @@ async function scanStatusJsonFast(opts: { const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) : null; - const channelsStatusPromise = resolveChannelsStatus({ cfg, gatewayReachable, opts }); const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); - const [channelsStatus, memory] = await Promise.all([channelsStatusPromise, memoryPromise]); - const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; + const memory = await memoryPromise; return { cfg, @@ -270,7 +268,7 @@ async function scanStatusJsonFast(opts: { gatewayProbe, gatewayReachable, gatewaySelf, - channelIssues, + channelIssues: [], agentStatus, channels: { rows: [], details: [] }, summary, From a97e1e1611aa3dd6e4307198da4f6691b108f5d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:47:49 -0700 Subject: [PATCH 067/331] fix(plugins): tighten lazy setup typing --- extensions/slack/src/channel.setup.ts | 4 ++-- src/commands/onboard-channels.ts | 2 +- src/commands/onboarding/registry.ts | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 2f7b888ca18..83cd1625059 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -7,6 +7,7 @@ import { buildChannelConfigSchema, getChatChannelMeta, inspectSlackAccount, + isSlackInteractiveRepliesEnabled, listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, @@ -68,8 +69,7 @@ export const slackSetupPlugin: ChannelPlugin = { }, agentPrompt: { messageToolHints: ({ cfg, accountId }) => - cfg.channels?.slack?.accounts?.[accountId ?? "default"]?.capabilities?.interactiveReplies === - true || cfg.channels?.slack?.capabilities?.interactiveReplies === true + isSlackInteractiveRepliesEnabled({ cfg, accountId }) ? [ "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cd269ac2cf9..c70fbde04ab 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -280,7 +280,7 @@ async function maybeConfigureDmPolicies(params: { resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter; + const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 01bc0deeb7a..9d7711e3092 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -51,17 +51,23 @@ export async function loadBundledChannelOnboardingPlugin( ): Promise { switch (channel) { case "discord": - return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; + return (await import("../../../extensions/discord/setup-entry.js")).default + .plugin as ChannelPlugin; case "imessage": - return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; + return (await import("../../../extensions/imessage/setup-entry.js")).default + .plugin as ChannelPlugin; case "signal": - return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; + return (await import("../../../extensions/signal/setup-entry.js")).default + .plugin as ChannelPlugin; case "slack": - return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; + return (await import("../../../extensions/slack/setup-entry.js")).default + .plugin as ChannelPlugin; case "telegram": - return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; + return (await import("../../../extensions/telegram/setup-entry.js")).default + .plugin as ChannelPlugin; case "whatsapp": - return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; + return (await import("../../../extensions/whatsapp/setup-entry.js")).default + .plugin as ChannelPlugin; default: return undefined; } From 65ec4843e8383aada4ae600f279ece89f945057f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:51:56 +0000 Subject: [PATCH 068/331] fix: tighten outbound channel/plugin resolution --- src/infra/outbound/channel-selection.test.ts | 38 +++++++++++++++++++ src/infra/outbound/channel-selection.ts | 40 +++++++++++++++++--- src/infra/outbound/message-action-runner.ts | 6 +++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index da605dcdb63..9448b919312 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), + resolveOutboundChannelPlugin: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ listChannelPlugins: mocks.listChannelPlugins, })); +vi.mock("./channel-resolution.js", () => ({ + resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, +})); + import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -36,6 +41,10 @@ describe("listConfiguredMessageChannels", () => { beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); + mocks.resolveOutboundChannelPlugin.mockReset(); + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ + id: channel, + })); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -158,6 +167,35 @@ describe("resolveMessageChannelSelection", () => { ).rejects.toThrow("Unknown channel: channel:c123"); }); + it("falls back when the explicit known channel is unavailable in the active plugin registry", async () => { + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => + channel === "slack" ? { id: "slack" } : undefined, + ); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + fallbackChannel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "tool-context-fallback", + }); + }); + + it("throws unavailable when a known channel has no active plugin", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + + await expect( + resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + }), + ).rejects.toThrow("Channel is unavailable: discord"); + }); + it("throws when no channel is provided and nothing is configured", async () => { await expect( resolveMessageChannelSelection({ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 9fbd592a589..024fc2273f6 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelSelectionSource = @@ -34,6 +35,22 @@ function resolveKnownChannel(value?: string | null): MessageChannelId | undefine return normalized as MessageChannelId; } +function resolveAvailableKnownChannel(params: { + cfg: OpenClawConfig; + value?: string | null; +}): MessageChannelId | undefined { + const normalized = resolveKnownChannel(params.value); + if (!normalized) { + return undefined; + } + return resolveOutboundChannelPlugin({ + channel: normalized, + cfg: params.cfg, + }) + ? normalized + : undefined; +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -94,8 +111,15 @@ export async function resolveMessageChannelSelection(params: { }> { const normalized = normalizeMessageChannel(params.channel); if (normalized) { - if (!isKnownChannel(normalized)) { - const fallback = resolveKnownChannel(params.fallbackChannel); + const availableExplicit = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: normalized, + }); + if (!availableExplicit) { + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, @@ -103,16 +127,22 @@ export async function resolveMessageChannelSelection(params: { source: "tool-context-fallback", }; } - throw new Error(`Unknown channel: ${String(normalized)}`); + if (!isKnownChannel(normalized)) { + throw new Error(`Unknown channel: ${String(normalized)}`); + } + throw new Error(`Channel is unavailable: ${String(normalized)}`); } return { - channel: normalized as MessageChannelId, + channel: availableExplicit, configured: await listConfiguredMessageChannels(params.cfg), source: "explicit", }; } - const fallback = resolveKnownChannel(params.fallbackChannel); + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0b6ad1ba16e..088baf75c22 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -20,6 +20,7 @@ import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; import { throwIfAborted } from "./abort.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -670,6 +671,11 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise Date: Sun, 15 Mar 2026 19:53:51 -0700 Subject: [PATCH 069/331] fix(ci): repair security and route test fixtures --- extensions/mattermost/src/setup-surface.ts | 8 +++++++- src/cli/program/routes.test.ts | 7 +++++-- src/security/audit.test.ts | 9 ++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 2877541bba9..e1be50e662a 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,7 +1,13 @@ -import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput } from "openclaw/plugin-sdk/mattermost"; +import { + DEFAULT_ACCOUNT_ID, + applySetupAccountConfigPatch, + hasConfiguredSecretInput, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { isMattermostConfigured, mattermostSetupAdapter, diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index e7958a684a5..0eb92333c0a 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -32,9 +32,12 @@ describe("program routes", () => { await expect(route?.run(argv)).resolves.toBe(false); } - it("matches status route and always preloads plugins", () => { + it("matches status route and preloads plugins only for text output", () => { const route = expectRoute(["status"]); - expect(route?.loadPlugins).toBe(true); + expect(typeof route?.loadPlugins).toBe("function"); + const shouldLoad = route?.loadPlugins as (argv: string[]) => boolean; + expect(shouldLoad(["node", "openclaw", "status"])).toBe(true); + expect(shouldLoad(["node", "openclaw", "status", "--json"])).toBe(false); }); it("matches health route and preloads plugins only for text output", () => { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 84fcadf1f98..dd1040e1263 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1803,7 +1803,14 @@ description: test skill }); it("warns when multiple DM senders share the main session", async () => { - const cfg: OpenClawConfig = { session: { dmScope: "main" } }; + const cfg: OpenClawConfig = { + session: { dmScope: "main" }, + channels: { + whatsapp: { + enabled: true, + }, + }, + }; const plugins: ChannelPlugin[] = [ { id: "whatsapp", From a2cb81199e22d5425a1490736971b9a96cea3c2a Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:55:24 -0500 Subject: [PATCH 070/331] secrets: harden read-only SecretRef command paths and diagnostics (#47794) * secrets: harden read-only SecretRef resolution for status and audit * CLI: add SecretRef degrade-safe regression coverage * Docs: align SecretRef status and daemon probe semantics * Security audit: close SecretRef review gaps * Security audit: preserve source auth SecretRef configuredness * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/daemon.md | 4 +- docs/cli/doctor.md | 1 + docs/cli/gateway.md | 3 +- docs/cli/index.md | 1 + docs/cli/security.md | 8 + docs/cli/status.md | 1 + docs/gateway/secrets.md | 2 +- src/agents/tools/message-tool.ts | 13 +- src/cli/command-secret-gateway.test.ts | 33 ++- src/cli/command-secret-gateway.ts | 43 ++- src/cli/command-secret-targets.test.ts | 10 + src/cli/command-secret-targets.ts | 5 + src/cli/daemon-cli/status.gather.test.ts | 73 +++++- src/cli/daemon-cli/status.gather.ts | 39 ++- src/cli/daemon-cli/status.print.ts | 3 + src/cli/security-cli.test.ts | 245 ++++++++++++++++++ src/cli/security-cli.ts | 39 ++- src/commands/channel-account-context.test.ts | 67 +++++ src/commands/channel-account-context.ts | 146 ++++++++++- .../channels.status.command-flow.test.ts | 172 ++++++++++++ src/commands/channels/resolve.ts | 2 +- src/commands/channels/status.ts | 2 +- src/commands/doctor-config-flow.ts | 2 +- src/commands/doctor-security.test.ts | 26 ++ src/commands/doctor-security.ts | 10 +- ...rns-state-directory-is-missing.e2e.test.ts | 48 ++++ src/commands/gateway-status.test.ts | 34 ++- src/commands/gateway-status.ts | 2 +- src/commands/health.ts | 148 +++++++++-- src/commands/status-all.ts | 2 +- src/commands/status.link-channel.test.ts | 55 ++++ src/commands/status.link-channel.ts | 5 +- src/commands/status.scan.ts | 4 +- src/commands/status.test.ts | 5 + src/gateway/call.ts | 7 +- src/gateway/probe-auth.ts | 12 + src/security/audit-channel.ts | 84 +++++- src/security/audit.test.ts | 77 +++++- src/security/audit.ts | 37 ++- 40 files changed, 1368 insertions(+), 103 deletions(-) create mode 100644 src/cli/security-cli.test.ts create mode 100644 src/commands/channels.status.command-flow.test.ts create mode 100644 src/commands/status.link-channel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f46e450d164..232cbb167a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. ### Fixes diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 8f6042e7400..f21c3930ece 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -34,13 +34,15 @@ openclaw daemon uninstall ## Common options -- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json` +- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` - lifecycle (`uninstall|start|stop|restart`): `--json` Notes: - `status` resolves configured auth SecretRefs for probe auth when possible. +- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 90e5fa7d7a2..4718135ee68 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -31,6 +31,7 @@ Notes: - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). +- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. ## macOS: `launchctl` env overrides diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 16b05baefce..d36fbde6c35 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -111,7 +111,8 @@ Options: Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. -- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy. - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). diff --git a/docs/cli/index.md b/docs/cli/index.md index fbc0bf1378f..f99b04efece 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -783,6 +783,7 @@ Notes: - `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. +- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds). - On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). diff --git a/docs/cli/security.md b/docs/cli/security.md index cc705b31a30..76a7ae75976 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -19,6 +19,8 @@ Related: ```bash openclaw security audit openclaw security audit --deep +openclaw security audit --deep --password +openclaw security audit --deep --token openclaw security audit --fix openclaw security audit --json ``` @@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). +SecretRef behavior: + +- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths. +- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing). +- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings. + ## JSON output Use `--json` for CI/policy checks: diff --git a/docs/cli/status.md b/docs/cli/status.md index 856c341b036..770bf6ab50d 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -26,3 +26,4 @@ Notes: - Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)). - Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible. - If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`. +- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 93cd508d4f1..379e4a527d4 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R There are two broad behaviors: - Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable. -- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. +- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. Read-only behavior: diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 63963ab5f38..b4ec54d62dd 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -12,6 +12,8 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -709,7 +711,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config ?? loadConfig(); + const cfg = options?.config + ? options.config + : ( + await resolveCommandSecretRefsViaGateway({ + config: loadConfig(), + commandName: "tools.message", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "enforce_resolved", + }) + ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 74c47f637e9..c9de91d4257 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { async function resolveTalkApiKey(params: { envKey: string; commandName?: string; - mode?: "strict" | "summary"; + mode?: "enforce_resolved" | "read_only_status"; }) { return resolveCommandSecretRefsViaGateway({ config: makeTalkApiKeySecretRefConfig(params.envKey), @@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); - it("degrades unresolved refs in summary mode instead of throwing", async () => { + it("degrades unresolved refs in read-only status mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; callGateway.mockResolvedValueOnce({ assignments: [], @@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); @@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); }); + it("accepts legacy summary mode as a read-only alias", async () => { + const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING"; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + await withEnvValue(envKey, undefined, async () => { + const result = await resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(envKey), + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + }); + }); + it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; callGateway.mockResolvedValueOnce({ @@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); @@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "status", targetIds: new Set(["talk.apiKey"]), - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); @@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); - it("degrades unresolved refs in operational read-only mode", async () => { + it("degrades unresolved refs in read-only operational mode", async () => { const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; const priorValue = process.env[envKey]; delete process.env[envKey]; @@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "channels resolve", targetIds: new Set(["talk.apiKey"]), - mode: "operational_readonly", + mode: "read_only_operational", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 03e578b642c..8b2b73c9f0f 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = { hadUnresolvedTargets: boolean; }; -export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret +export type CommandSecretResolutionMode = + | "enforce_resolved" + | "read_only_status" + | "read_only_operational"; + +type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret + +type CommandSecretResolutionModeInput = + | CommandSecretResolutionMode + | LegacyCommandSecretResolutionMode; export type CommandSecretTargetState = | "resolved_gateway" @@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ "tools.web.fetch.firecrawl.", ] as const; +function normalizeCommandSecretResolutionMode( + mode?: CommandSecretResolutionModeInput, +): CommandSecretResolutionMode { + if (!mode || mode === "enforce_resolved" || mode === "strict") { + return "enforce_resolved"; + } + if (mode === "read_only_status" || mode === "summary") { + return "read_only_status"; + } + return "read_only_operational"; +} + +function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean { + return mode === "enforce_resolved"; +} + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: { context, }); } catch (error) { - if (params.mode === "strict") { + if (enforcesResolvedSecrets(params.mode)) { throw error; } localResolutionDiagnostics.push( @@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: { analyzed, resolvedState: "resolved_local", }); - if (params.mode !== "strict" && analyzed.unresolved.length > 0) { + if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) { scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); } else if (analyzed.unresolved.length > 0) { throw new Error( @@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics( unresolved: UnresolvedCommandSecretAssignment[], mode: CommandSecretResolutionMode, ): string[] { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { return []; } return unresolved.map( @@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: { }); setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved); } catch (error) { - if (params.mode !== "strict") { + if (!enforcesResolvedSecrets(params.mode)) { params.localResolutionDiagnostics.push( `${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`, ); @@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: { config: OpenClawConfig; commandName: string; targetIds: Set; - mode?: CommandSecretResolutionMode; + mode?: CommandSecretResolutionModeInput; }): Promise { - const mode = params.mode ?? "strict"; + const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, @@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { (entry) => !recoveredPaths.has(entry.path), ); if (stillUnresolved.length > 0) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw new Error( `${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, ); @@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { ]); } } catch (error) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw error; } scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index a71ac5e00c4..22a23b36055 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; describe("command secret target ids", () => { @@ -21,4 +22,13 @@ describe("command secret target ids", () => { ]), ); }); + + it("includes gateway auth and channel targets for security audit", () => { + const ids = getSecurityAuditCommandSecretTargetIds(); + expect(ids.has("channels.discord.token")).toBe(true); + expect(ids.has("gateway.auth.token")).toBe(true); + expect(ids.has("gateway.auth.password")).toBe(true); + expect(ids.has("gateway.remote.token")).toBe(true); + expect(ids.has("gateway.remote.password")).toBe(true); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index e1c2c49e0ae..d6dde83cd19 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = { "agents.defaults.memorySearch.remote.", "agents.list[].memorySearch.remote.", ]), + securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]), } as const; function toTargetIdSet(values: readonly string[]): Set { @@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set { export function getStatusCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.status); } + +export function getSecurityAuditCommandSecretTargetIds(): Set { + return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit); +} diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 27b53753eda..fd94acca3a9 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; -const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); +const callGatewayStatusProbe = vi.fn< + (opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }> +>(async (_opts?: unknown) => ({ + ok: true, + url: "ws://127.0.0.1:19001", + error: null, +})); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ enabled: true, required: true, @@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => { ); }); + it("degrades safely when daemon probe auth SecretRef is unresolved", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + password: undefined, + }), + ); + expect(status.rpc?.authWarning).toBeUndefined(); + }); + + it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: false, + error: "gateway closed", + url: "wss://127.0.0.1:19001", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(status.rpc?.ok).toBe(false); + expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable"); + expect(status.rpc?.authWarning).toContain("probing without configured auth credentials"); + }); + it("keeps remote probe auth strict when remote token is missing", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 707a908b1f6..4647b789ff9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { trimToUndefined } from "../../gateway/credentials.js"; +import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; @@ -112,6 +112,7 @@ export type DaemonStatus = { ok: boolean; error?: string; url?: string; + authWarning?: string; }; health?: { healthy: boolean; @@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool return true; } +function parseGatewaySecretRefPathFromError(error: unknown): string | null { + return isGatewaySecretRefUnavailableError(error) ? error.path : null; +} + async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { @@ -310,8 +315,11 @@ export async function gatherDaemonStatus( const tlsRuntime = shouldUseLocalTlsRuntime ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) : undefined; - const daemonProbeAuth = opts.probe - ? await resolveGatewayProbeAuthWithSecretInputs({ + let daemonProbeAuth: { token?: string; password?: string } | undefined; + let rpcAuthWarning: string | undefined; + if (opts.probe) { + try { + daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ cfg: daemonCfg, mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", env: mergedDaemonEnv as NodeJS.ProcessEnv, @@ -319,8 +327,16 @@ export async function gatherDaemonStatus( token: opts.rpc.token, password: opts.rpc.password, }, - }) - : undefined; + }); + } catch (error) { + const refPath = parseGatewaySecretRefPathFromError(error); + if (!refPath) { + throw error; + } + daemonProbeAuth = undefined; + rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + } + } const rpc = opts.probe ? await probeGatewayStatus({ @@ -336,6 +352,9 @@ export async function gatherDaemonStatus( configPath: daemonConfigSummary.path, }) : undefined; + if (rpc?.ok) { + rpcAuthWarning = undefined; + } const health = opts.probe && loaded ? await inspectGatewayRestart({ @@ -369,7 +388,15 @@ export async function gatherDaemonStatus( port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, - ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}), + ...(rpc + ? { + rpc: { + ...rpc, + url: gateway.probeUrl, + ...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}), + }, + } + : {}), ...(health ? { health: { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 91348d10d4a..088a3654797 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`); } else { defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`); + if (rpc.authWarning) { + defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`); + } if (rpc.url) { defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`); } diff --git a/src/cli/security-cli.test.ts b/src/cli/security-cli.test.ts new file mode 100644 index 00000000000..95c3e62d4ae --- /dev/null +++ b/src/cli/security-cli.test.ts @@ -0,0 +1,245 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn(); +const runSecurityAudit = vi.fn(); +const fixSecurityFootguns = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const getSecurityAuditCommandSecretTargetIds = vi.fn( + () => new Set(["gateway.auth.token", "gateway.auth.password"]), +); + +const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../security/audit.js", () => ({ + runSecurityAudit: (opts: unknown) => runSecurityAudit(opts), +})); + +vi.mock("../security/fix.js", () => ({ + fixSecurityFootguns: () => fixSecurityFootguns(), +})); + +vi.mock("./command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./command-secret-targets.js", () => ({ + getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(), +})); + +const { registerSecurityCli } = await import("./security-cli.js"); + +function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSecurityCli(program); + return program; +} + +describe("security CLI", () => { + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + runSecurityAudit.mockReset(); + fixSecurityFootguns.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + getSecurityAuditCommandSecretTargetIds.mockClear(); + fixSecurityFootguns.mockResolvedValue({ + changes: [], + actions: [], + errors: [], + }); + }); + + it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => { + const sourceConfig = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig = { + ...sourceConfig, + gateway: { + ...sourceConfig.gateway, + auth: { + ...sourceConfig.gateway.auth, + token: "resolved-token", + }, + }, + }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 1, info: 0 }, + findings: [ + { + checkId: "gateway.probe_failed", + severity: "warn", + title: "Gateway probe failed (deep)", + detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789", + }, + ], + }); + + await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" }); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: sourceConfig, + commandName: "security audit", + mode: "read_only_status", + targetIds: expect.any(Set), + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + config: resolvedConfig, + sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); + const payload = JSON.parse(String(runtimeLogs.at(-1))); + expect(payload.secretDiagnostics).toEqual([ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ]); + }); + + it("forwards --token to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--token", "explicit-token", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { token: "explicit-token" }, + }), + ); + }); + + it("forwards --password to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--password", "explicit-password", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { password: "explicit-password" }, + }), + ); + }); + + it("forwards both --token and --password to deep probe auth", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + [ + "security", + "audit", + "--deep", + "--token", + "explicit-token", + "--password", + "explicit-password", + "--json", + ], + { + from: "user", + }, + ); + + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { + token: "explicit-token", + password: "explicit-password", + }, + }), + ); + }); +}); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index f55f657f4c1..586e5e0f114 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; +import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js"; import { formatHelpExamples } from "./help-format.js"; type SecurityAuditOptions = { json?: boolean; deep?: boolean; fix?: boolean; + token?: string; + password?: string; }; function formatSummary(summary: { critical: number; warn: number; info: number }): string { @@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) { `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw security audit", "Run a local security audit."], ["openclaw security audit --deep", "Include best-effort live Gateway probe checks."], + ["openclaw security audit --deep --token ", "Use explicit token for deep probe."], + [ + "openclaw security audit --deep --password ", + "Use explicit password for deep probe.", + ], ["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."], ["openclaw security audit --json", "Output machine-readable JSON."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`, @@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) { .command("audit") .description("Audit config + local state for common security foot-guns") .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--token ", "Use explicit gateway token for deep probe auth") + .option("--password ", "Use explicit gateway password for deep probe auth") .option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false) .option("--json", "Print JSON", false) .action(async (opts: SecurityAuditOptions) => { const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; - const cfg = loadConfig(); + const sourceConfig = loadConfig(); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: sourceConfig, + commandName: "security audit", + targetIds: getSecurityAuditCommandSecretTargetIds(), + mode: "read_only_status", + }); const report = await runSecurityAudit({ config: cfg, + sourceConfig, deep: Boolean(opts.deep), includeFilesystem: true, includeChannelSecurity: true, + deepProbeAuth: + opts.token?.trim() || opts.password?.trim() + ? { + ...(opts.token?.trim() ? { token: opts.token } : {}), + ...(opts.password?.trim() ? { password: opts.password } : {}), + } + : undefined, }); if (opts.json) { defaultRuntime.log( - JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2), + JSON.stringify( + fixResult + ? { fix: fixResult, report, secretDiagnostics } + : { ...report, secretDiagnostics }, + null, + 2, + ), ); return; } @@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) { lines.push(heading("OpenClaw security audit")); lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`)); + for (const diagnostic of secretDiagnostics) { + lines.push(muted(`[secrets] ${diagnostic}`)); + } if (opts.fix) { lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`)); diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index 9fdaadb5231..4cdbde4d7e2 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.account).toBe(account); expect(result.enabled).toBe(true); expect(result.configured).toBe(true); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); }); it("uses plugin enable/configure hooks", async () => { @@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => { expect(isConfigured).toHaveBeenCalledWith(account, {}); expect(result.enabled).toBe(false); expect(result.configured).toBe(false); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); + }); + + it("keeps strict mode fail-closed when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow( + /missing secret/i, + ); + }); + + it("degrades safely in read_only mode when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + commandName: "status", + }); + + expect(result.enabled).toBe(false); + expect(result.configured).toBe(false); + expect(result.degraded).toBe(true); + expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe( + true, + ); + }); + + it("prefers inspectAccount in read_only mode", async () => { + const inspectAccount = vi.fn(() => ({ configured: true, enabled: true })); + const resolveAccount = vi.fn(() => ({ configured: false, enabled: false })); + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-1"], + inspectAccount, + resolveAccount, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + }); + + expect(inspectAccount).toHaveBeenCalled(); + expect(resolveAccount).not.toHaveBeenCalled(); + expect(result.enabled).toBe(true); + expect(result.configured).toBe(true); + expect(result.degraded).toBe(true); }); }); diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index 36ce8c53e72..c997ec3e18a 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -1,6 +1,8 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; +import { formatErrorMessage } from "../infra/errors.js"; export type ChannelDefaultAccountContext = { accountIds: string[]; @@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = { account: unknown; enabled: boolean; configured: boolean; + diagnostics: string[]; + /** + * Indicates read-only resolution was used instead of strict full-account resolution. + * This is expected for read_only mode and does not necessarily mean an error occurred. + */ + degraded: boolean; }; +export type ChannelAccountContextMode = "strict" | "read_only"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function getBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +function formatContextDiagnostic(params: { + commandName?: string; + pluginId: string; + accountId: string; + message: string; +}): string { + const prefix = params.commandName ? `${params.commandName}: ` : ""; + return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`; +} + export async function resolveDefaultChannelAccountContext( plugin: ChannelPlugin, cfg: OpenClawConfig, + options?: { mode?: ChannelAccountContextMode; commandName?: string }, ): Promise { + const mode = options?.mode ?? "strict"; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; - return { accountIds, defaultAccountId, account, enabled, configured }; + if (mode === "strict") { + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics: [], + degraded: false, + }; + } + + const diagnostics: string[] = []; + let degraded = false; + + const inspected = + plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId: defaultAccountId, + }); + + let account = inspected; + if (!account) { + try { + account = plugin.config.resolveAccount(cfg, defaultAccountId); + } catch (error) { + degraded = true; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`, + }), + ); + return { + accountIds, + defaultAccountId, + account: {}, + enabled: false, + configured: false, + diagnostics, + degraded, + }; + } + } else { + degraded = true; + } + + const inspectEnabled = getBooleanField(account, "enabled"); + let enabled = inspectEnabled ?? true; + if (inspectEnabled === undefined && plugin.config.isEnabled) { + try { + enabled = plugin.config.isEnabled(account, cfg); + } catch (error) { + degraded = true; + enabled = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`, + }), + ); + } + } + + const inspectConfigured = getBooleanField(account, "configured"); + let configured = inspectConfigured ?? true; + if (inspectConfigured === undefined && plugin.config.isConfigured) { + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + degraded = true; + configured = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`, + }), + ); + } + } + + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics, + degraded, + }; } diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts new file mode 100644 index 00000000000..e613c64323a --- /dev/null +++ b/src/commands/channels.status.command-flow.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const requireValidConfigSnapshot = vi.fn(); +const listChannelPlugins = vi.fn(); +const withProgress = vi.fn(async (_opts: unknown, run: () => Promise) => await run()); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./shared.js", () => ({ + requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime), + formatChannelAccountLabel: ({ + channel, + accountId, + }: { + channel: string; + accountId: string; + name?: string; + }) => `${channel} ${accountId}`, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => listChannelPlugins(), + getChannelPlugin: (channel: string) => + (listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: (opts: unknown, run: () => Promise) => withProgress(opts, run), +})); + +const { channelsStatusCommand } = await import("./channels/status.js"); + +function createTokenOnlyPlugin() { + return { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + inspectAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + resolveAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function createRuntimeCapture() { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: (_code?: number) => undefined, + }; + return { runtime, logs, errors }; +} + +describe("channelsStatusCommand SecretRef fallback flow", () => { + beforeEach(() => { + callGateway.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + requireValidConfigSnapshot.mockReset(); + listChannelPlugins.mockReset(); + withProgress.mockClear(); + listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); + }); + + it("keeps read-only fallback output when SecretRefs are unresolved", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: false, channels: {} }, + diagnostics: [ + "channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: true, + }); + const { runtime, logs, errors } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true); + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "channels status", + mode: "read_only_status", + }), + ); + expect( + logs.some((line) => + line.includes("[secrets] channels status: channels.discord.token is unavailable"), + ), + ).toBe(true); + const joined = logs.join("\n"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("token:config (unavailable)"); + }); + + it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: true, channels: {} }, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + const { runtime, logs } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + const joined = logs.join("\n"); + expect(joined).toContain("configured"); + expect(joined).toContain("token:config"); + expect(joined).not.toContain("secret unavailable in this command path"); + expect(joined).not.toContain("token:config (unavailable)"); + }); +}); diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index e9e0345871f..7a29b4993f5 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), - mode: "operational_readonly", + mode: "read_only_operational", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 3a56810e44c..2cbdaf17726 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -315,7 +315,7 @@ export async function channelsStatusCommand( config: cfg, commandName: "channels status", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f616bfaba55..a06c090f9f4 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi config: cfg, commandName: "doctor --fix", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { const inspected = inspectTelegramAccount({ cfg, accountId }); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index c91ed2087a4..ca2bfb2989c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).toContain("direct/DM targets by default"); }); + it("degrades safely when channel account resolution fails in read-only security checks", async () => { + pluginRegistry.list = [ + { + id: "whatsapp", + meta: { label: "WhatsApp" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + isEnabled: () => true, + isConfigured: () => true, + }, + security: { + resolveDmPolicy: () => null, + }, + }, + ]; + + await noteSecurityWarnings({} as OpenClawConfig); + const message = lastMessage(); + expect(message).toContain("[secrets]"); + expect(message).toContain("failed to resolve account"); + expect(message).toContain("Run: openclaw security audit --deep"); + }); + it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => { const cfg = { agents: { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 5ba17c1c751..c489682f607 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (!plugin.security) { continue; } - const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + const { defaultAccountId, account, enabled, configured, diagnostics } = + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "doctor", + }); + for (const diagnostic of diagnostics) { + warnings.push(`- [secrets] ${diagnostic}`); + } if (!enabled) { continue; } diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 68d865996d2..11a382db241 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -122,4 +122,52 @@ describe("doctor command", () => { "openclaw config set gateway.auth.mode password", ); }); + + it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => { + mockDoctorConfigSnapshot({ + config: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + }); + + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + note.mockClear(); + try { + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + } finally { + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + } + + const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth"); + expect(gatewayAuthNote).toBeTruthy(); + expect(String(gatewayAuthNote?.[0])).toContain( + "Gateway token is managed via SecretRef and is currently unavailable.", + ); + expect(String(gatewayAuthNote?.[0])).toContain( + "Doctor will not overwrite gateway.auth.token with a plaintext value.", + ); + }); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 452bcb3691b..46212816410 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -268,7 +268,7 @@ describe("gateway-status command", () => { expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); }); - it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { + it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { mockLocalTokenEnvRefConfig(); @@ -276,6 +276,38 @@ describe("gateway-status command", () => { await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { + mockLocalTokenEnvRefConfig(); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connection refused", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow( + "__exit__:1", + ); + }); + expect(runtimeErrors).toHaveLength(0); const parsed = JSON.parse(runtimeLogs.join("\n")) as { warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index be0b9abf69a..ff2ba419cc8 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -229,7 +229,7 @@ export async function gatewayStatusCommand( }); } for (const result of probed) { - if (result.authDiagnostics.length === 0) { + if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) { continue; } for (const diagnostic of result.authDiagnostics) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 56705c96270..0e54eebadc7 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,7 +1,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, readBestEffortConfig } from "../config/config.js"; @@ -161,17 +162,91 @@ const buildSessionSummary = (storePath: string) => { } satisfies HealthSummary["sessions"]; }; -const isAccountEnabled = (account: unknown): boolean => { - if (!account || typeof account !== "object") { - return true; - } - const enabled = (account as { enabled?: boolean }).enabled; - return enabled !== false; -}; - const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; +function inspectHealthAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +): unknown { + return ( + plugin.config.inspectAccount?.(cfg, accountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + }) + ); +} + +function readBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +async function resolveHealthAccountContext(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; +}): Promise<{ + account: unknown; + enabled: boolean; + configured: boolean; + diagnostics: string[]; +}> { + const diagnostics: string[] = []; + let account: unknown; + try { + account = params.plugin.config.resolveAccount(params.cfg, params.accountId); + } catch (error) { + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + account = inspectHealthAccount(params.plugin, params.cfg, params.accountId); + } + + if (!account) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } + + const enabledFallback = readBooleanField(account, "enabled") ?? true; + let enabled = enabledFallback; + if (params.plugin.config.isEnabled) { + try { + enabled = params.plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = enabledFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + const configuredFallback = readBooleanField(account, "configured") ?? true; + let configured = configuredFallback; + if (params.plugin.config.isConfigured) { + try { + configured = await params.plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = configuredFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; +} + const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { const record = asRecord(probe); if (!record) { @@ -416,13 +491,14 @@ export async function getHealthSnapshot(params?: { const accountSummaries: Record = {}; for (const accountId of accountIdsToProbe) { - const account = plugin.config.resolveAccount(cfg, accountId); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); + if (diagnostics.length > 0) { + debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics }); + } let probe: unknown; let lastProbeAt: number | null = null; @@ -588,16 +664,20 @@ export async function healthCommand( ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, ); for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + const { account, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); const record = asRecord(account); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; runtime.log( ` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`, ); + for (const diagnostic of diagnostics) { + runtime.log(` ! ${diagnostic}`); + } } } runtime.log(info("[debug] bindings map")); @@ -691,13 +771,31 @@ export async function healthCommand( defaultAccountId, boundAccounts, }); - const account = plugin.config.resolveAccount(cfg, accountId); - plugin.status.logSelfId({ - account, + const accountContext = await resolveHealthAccountContext({ + plugin, cfg, - runtime, - includeChannelPrefix: true, + accountId, }); + if (!accountContext.enabled || !accountContext.configured) { + continue; + } + if (accountContext.diagnostics.length > 0) { + continue; + } + try { + plugin.status.logSelfId({ + account: accountContext.account, + cfg, + runtime, + includeChannelPrefix: true, + }); + } catch (error) { + debugHealth("logSelfId.failed", { + channel: plugin.id, + accountId, + error: formatErrorMessage(error), + }); + } } if (resolvedAgents.length > 0) { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index fa4e3dcb435..b643c30ff33 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -48,7 +48,7 @@ export async function statusAllCommand( config: loadedRaw, commandName: "status --all", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); diff --git a/src/commands/status.link-channel.test.ts b/src/commands/status.link-channel.test.ts new file mode 100644 index 00000000000..14315ef1a35 --- /dev/null +++ b/src/commands/status.link-channel.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => pluginRegistry.list, +})); + +import { resolveLinkChannelContext } from "./status.link-channel.js"; + +describe("resolveLinkChannelContext", () => { + it("returns linked context from read-only inspected account state", async () => { + const account = { configured: true, enabled: true }; + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => { + throw new Error("should not be called in read-only mode"); + }, + }, + status: { + buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }), + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result?.linked).toBe(true); + expect(result?.authAgeMs).toBe(1234); + expect(result?.account).toBe(account); + }); + + it("degrades safely when account resolution throws", async () => { + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result).toBeNull(); + }); +}); diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 2ee0eee4f2e..4f192f31623 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -16,7 +16,10 @@ export async function resolveLinkChannelContext( ): Promise { for (const plugin of listChannelPlugins()) { const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "status", + }); const snapshot = plugin.config.describeAccount ? plugin.config.describeAccount(account, cfg) : ({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 8de4aae7745..f7661573578 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -197,7 +197,7 @@ async function scanStatusJsonFast(opts: { config: loadedRaw, commandName: "status --json", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); @@ -302,7 +302,7 @@ export async function scanStatus( config: loadedRaw, commandName: "status", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5cc71b6e950..f3dfd37064a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -512,6 +512,11 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { + expect( + payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), + ).toBe(true); + } expect(runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index f163a45ef06..300391b6047 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: { value: params.value, env: params.env, normalize: trimToUndefined, - onResolveRefError: (error) => { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); + onResolveRefError: () => { + throw new GatewaySecretRefUnavailableError(params.path); }, }); if (!value) { diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 64980be601e..2c624acaa00 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: { cfg: OpenClawConfig; mode: "local" | "remote"; env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; }): { auth: { token?: string; password?: string }; warning?: string; } { + const explicitToken = params.explicitAuth?.token?.trim(); + const explicitPassword = params.explicitAuth?.password?.trim(); + if (explicitToken || explicitPassword) { + return { + auth: { + ...(explicitToken ? { token: explicitToken } : {}), + ...(explicitPassword ? { password: explicitPassword } : {}), + }, + }; + } + try { return { auth: resolveGatewayProbeAuth(params) }; } catch (error) { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ca0e69722e3..bf501cf659b 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; @@ -164,6 +165,7 @@ export async function collectChannelSecurityFindings(params: { plugin: (typeof params.plugins)[number], accountId: string, ) => { + const diagnostics: string[] = []; const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { @@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: { enabled?: boolean; configured?: boolean; } | null; - const resolvedAccount = - resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId); + let resolvedAccount = resolvedInspectedAccount; + if (!resolvedAccount) { + try { + resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId); + } catch (error) { + diagnostics.push( + `${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + } + } + if (!resolvedAccount && sourceInspectedAccount) { + resolvedAccount = sourceInspectedAccount; + } + if (!resolvedAccount) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } const useSourceUnavailableAccount = Boolean( sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && @@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: { const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount; const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection; const accountRecord = asAccountRecord(account); - const enabled = + let enabled = typeof selectedInspection?.enabled === "boolean" ? selectedInspection.enabled : typeof accountRecord?.enabled === "boolean" ? accountRecord.enabled - : plugin.config.isEnabled - ? plugin.config.isEnabled(account, params.cfg) - : true; - const configured = + : true; + if ( + typeof selectedInspection?.enabled !== "boolean" && + typeof accountRecord?.enabled !== "boolean" && + plugin.config.isEnabled + ) { + try { + enabled = plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + let configured = typeof selectedInspection?.configured === "boolean" ? selectedInspection.configured : typeof accountRecord?.configured === "boolean" ? accountRecord.configured - : plugin.config.isConfigured - ? await plugin.config.isConfigured(account, params.cfg) - : true; - return { account, enabled, configured }; + : true; + if ( + typeof selectedInspection?.configured !== "boolean" && + typeof accountRecord?.configured !== "boolean" && + plugin.config.isConfigured + ) { + try { + configured = await plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; }; const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { @@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: { plugin.id, accountId, ); - const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId); + const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount( + plugin, + accountId, + ); + for (const diagnostic of diagnostics) { + findings.push({ + checkId: `channels.${plugin.id}.account.read_only_resolution`, + severity: "warn", + title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`, + detail: diagnostic, + remediation: + "Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.", + }); + } if (!enabled) { continue; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dd1040e1263..dedc789773c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -346,6 +346,43 @@ description: test skill expectNoFinding(res, "gateway.bind_no_auth"); }); + it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expectNoFinding(res, "gateway.bind_no_auth"); + }); + it("evaluates gateway auth rate-limit warning based on configuration", async () => { const cases: Array<{ name: string; @@ -1805,11 +1842,7 @@ description: test skill it("warns when multiple DM senders share the main session", async () => { const cfg: OpenClawConfig = { session: { dmScope: "main" }, - channels: { - whatsapp: { - enabled: true, - }, - }, + channels: { whatsapp: { enabled: true } }, }; const plugins: ChannelPlugin[] = [ { @@ -1984,6 +2017,40 @@ description: test skill }); }); + it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { + const plugin = stubChannelPlugin({ + id: "zalouser", + label: "Zalo Personal", + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing SecretRef"); + }, + }); + + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [plugin], + }); + + const finding = res.findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.title).toContain("could not be fully resolved"); + expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding?.detail).toContain("missing SecretRef"); + }); + it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { await withChannelSecurityStateDir(async () => { const sourceConfig: OpenClawConfig = { diff --git a/src/security/audit.ts b/src/security/audit.ts index dbbfb9651be..d3c1337e042 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -113,6 +113,8 @@ export type SecurityAuditOptions = { configSnapshot?: ConfigFileSnapshot | null; /** Optional cache for code-safety summaries across repeated deep audits. */ codeSafetySummaryCache?: Map>; + /** Optional explicit auth for deep gateway probe. */ + deepProbeAuth?: { token?: string; password?: string }; }; type AuditExecutionContext = { @@ -132,6 +134,7 @@ type AuditExecutionContext = { plugins?: ReturnType; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; + deepProbeAuth?: { token?: string; password?: string }; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: { function collectGatewayConfigFindings( cfg: OpenClawConfig, + sourceConfig: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; @@ -365,18 +369,18 @@ function collectGatewayConfigFindings( hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) || hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD); const tokenConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.token, + sourceConfig.secrets?.defaults, ); const passwordConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.password, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.password, + sourceConfig.secrets?.defaults, ); const remoteTokenConfigured = hasConfiguredSecretInput( - cfg.gateway?.remote?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.remote?.token, + sourceConfig.secrets?.defaults, ); - const explicitAuthMode = cfg.gateway?.auth?.mode; + const explicitAuthMode = sourceConfig.gateway?.auth?.mode; const tokenCanWin = hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; const passwordCanWin = @@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: { env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; + explicitAuth?: { token?: string; password?: string }; }): Promise<{ deep: SecurityAuditReport["deep"]; authWarning?: string; @@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: { const authResolution = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" }) - : resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" }); + ? resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "local", + explicitAuth: params.explicitAuth, + }) + : resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "remote", + explicitAuth: params.explicitAuth, + }); const res = await params .probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs }) .catch((err) => ({ @@ -1144,6 +1159,7 @@ async function createAuditExecutionContext( plugins: opts.plugins, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), + deepProbeAuth: opts.deepProbeAuth, }; } @@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 19:55:08 -0700 Subject: [PATCH 071/331] Gateway: add presence-only probe mode for status --- src/commands/status.scan.test.ts | 3 +++ src/commands/status.scan.ts | 1 + src/gateway/probe.test.ts | 14 ++++++++++++++ src/gateway/probe.ts | 19 ++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index b94f1f0ece0..55f323f0b4a 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -246,6 +246,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ detailLevel: "presence" }), + ); expect(mocks.callGateway).not.toHaveBeenCalledWith( expect.objectContaining({ method: "channels.status" }), ); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index f7661573578..88dd21e7177 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -98,6 +98,7 @@ async function resolveGatewayProbeSnapshot(params: { url: gatewayConnection.url, auth: gatewayProbeAuthResolution.auth, timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + detailLevel: "presence", }).catch(() => null); if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { gatewayProbe.error = gatewayProbe.error diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 6cd7d64fc51..f91dc5148d5 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -81,4 +81,18 @@ describe("probeGateway", () => { expect(result.ok).toBe(true); expect(gatewayClientState.requests).toEqual([]); }); + + it("fetches only presence for presence-only probes", async () => { + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + detailLevel: "presence", + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.requests).toEqual(["system-presence"]); + expect(result.health).toBeNull(); + expect(result.status).toBeNull(); + expect(result.configSnapshot).toBeNull(); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 40740987fb0..87a77b8bfef 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -34,6 +34,7 @@ export async function probeGateway(opts: { auth?: GatewayProbeAuth; timeoutMs: number; includeDetails?: boolean; + detailLevel?: "none" | "presence" | "full"; }): Promise { const startedAt = Date.now(); const instanceId = randomUUID(); @@ -49,6 +50,8 @@ export async function probeGateway(opts: { } })(); + const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full"); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -79,7 +82,7 @@ export async function probeGateway(opts: { }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt; - if (opts.includeDetails === false) { + if (detailLevel === "none") { settle({ ok: true, connectLatencyMs, @@ -93,6 +96,20 @@ export async function probeGateway(opts: { return; } try { + if (detailLevel === "presence") { + const presence = await client.request("system-presence"); + settle({ + ok: true, + connectLatencyMs, + error: null, + close, + health: null, + status: null, + presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null, + configSnapshot: null, + }); + return; + } const [health, status, presence, configSnapshot] = await Promise.all([ client.request("health"), client.request("status"), From 84c0326f4de9970d0aac8c6187077d3e2cd24561 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:52:17 -0700 Subject: [PATCH 072/331] refactor: move group access into setup wizard --- extensions/discord/src/setup-core.ts | 2 +- extensions/matrix/src/setup-surface.ts | 137 +++++++------- extensions/msteams/src/setup-surface.ts | 179 +++++++++--------- extensions/slack/src/setup-core.ts | 2 +- extensions/twitch/src/setup-surface.ts | 68 ++++--- ...s => setup-group-access-configure.test.ts} | 41 +++- ...ure.ts => setup-group-access-configure.ts} | 15 +- ...ess.test.ts => setup-group-access.test.ts} | 23 ++- ...hannel-access.ts => setup-group-access.ts} | 8 +- src/channels/plugins/setup-wizard.ts | 42 ++-- src/plugin-sdk/googlechat.ts | 1 - src/plugin-sdk/irc.ts | 1 - src/plugin-sdk/tlon.ts | 1 - src/plugin-sdk/zalo.ts | 1 - src/plugin-sdk/zalouser.ts | 1 - 15 files changed, 305 insertions(+), 217 deletions(-) rename src/channels/plugins/{onboarding/channel-access-configure.test.ts => setup-group-access-configure.test.ts} (77%) rename src/channels/plugins/{onboarding/channel-access-configure.ts => setup-group-access-configure.ts} (65%) rename src/channels/plugins/{onboarding/channel-access.test.ts => setup-group-access.test.ts} (84%) rename src/channels/plugins/{onboarding/channel-access.ts => setup-group-access.ts} (92%) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index cec63dd01ec..f75a0312416 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -251,7 +251,7 @@ export function createDiscordSetupWizardProxy( prompter: { note: (message: string, title?: string) => Promise }; }) => { const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.groupAccess) { + if (!wizard.groupAccess?.resolveAllowlist) { return entries.map((input) => ({ input, resolved: false })); } try { diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index e01e0d57750..b475b6bf742 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,5 +1,4 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, @@ -171,6 +170,78 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } +async function resolveMatrixGroupRooms(params: { + cfg: CoreConfig; + entries: string[]; + prompter: Pick; +}): Promise { + if (params.entries.length === 0) { + return []; + } + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of params.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + const roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await params.prompter.note(resolution, "Matrix rooms"); + } + return roomKeys; + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + return params.entries.map((entry) => entry.trim()).filter(Boolean); + } +} + +const matrixGroupAccess: NonNullable = { + label: "Matrix rooms", + placeholder: "!roomId:server, #alias:server, Project Room", + currentPolicy: ({ cfg }) => cfg.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: ({ cfg }) => + Object.keys(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms ?? {}), + updatePrompt: ({ cfg }) => Boolean(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms), + setPolicy: ({ cfg, policy }) => setMatrixGroupPolicy(cfg as CoreConfig, policy), + resolveAllowlist: async ({ cfg, entries, prompter }) => + await resolveMatrixGroupRooms({ + cfg: cfg as CoreConfig, + entries, + prompter, + }), + applyAllowlist: ({ cfg, resolved }) => + setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), +}; + const matrixDmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, @@ -386,72 +457,10 @@ export const matrixSetupWizard: ChannelSetupWizard = { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } - const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Matrix rooms"); - } - } catch (err) { - await prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } - } - return { cfg: next }; }, dmPolicy: matrixDmPolicy, + groupAccess: matrixGroupAccess, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index f8db90e5079..9e39a24563e 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,5 +1,4 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { mergeAllowFromEntries, setTopLevelChannelAllowFrom, @@ -191,6 +190,96 @@ function setMSTeamsTeamsAllowlist( }; } +function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] { + return Object.entries(cfg.channels?.msteams?.teams ?? {}).flatMap(([teamKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + return [teamKey]; + } + return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); + }); +} + +async function resolveMSTeamsGroupAllowlist(params: { + cfg: OpenClawConfig; + entries: string[]; + prompter: Pick; +}): Promise> { + let resolvedEntries = params.entries + .map((entry) => parseMSTeamsTeamEntry(entry)) + .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; + if (params.entries.length === 0 || !resolveMSTeamsCredentials(params.cfg.channels?.msteams)) { + return resolvedEntries; + } + try { + const lookups = await resolveMSTeamsChannelAllowlist({ + cfg: params.cfg, + entries: params.entries, + }); + const resolvedChannels = lookups.filter( + (entry) => entry.resolved && entry.teamId && entry.channelId, + ); + const resolvedTeams = lookups.filter( + (entry) => entry.resolved && entry.teamId && !entry.channelId, + ); + const unresolved = lookups.filter((entry) => !entry.resolved).map((entry) => entry.input); + resolvedEntries = [ + ...resolvedChannels.map((entry) => ({ + teamKey: entry.teamId as string, + channelKey: entry.channelId as string, + })), + ...resolvedTeams.map((entry) => ({ + teamKey: entry.teamId as string, + })), + ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean), + ] as Array<{ teamKey: string; channelKey?: string }>; + const summary: string[] = []; + if (resolvedChannels.length > 0) { + summary.push( + `Resolved channels: ${resolvedChannels + .map((entry) => entry.channelId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (resolvedTeams.length > 0) { + summary.push( + `Resolved teams: ${resolvedTeams + .map((entry) => entry.teamId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (unresolved.length > 0) { + summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); + } + if (summary.length > 0) { + await params.prompter.note(summary.join("\n"), "MS Teams channels"); + } + return resolvedEntries; + } catch (err) { + await params.prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "MS Teams channels", + ); + return resolvedEntries; + } +} + +const msteamsGroupAccess: NonNullable = { + label: "MS Teams channels", + placeholder: "Team Name/Channel Name, teamId/conversationId", + currentPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy ?? "allowlist", + currentEntries: ({ cfg }) => listMSTeamsGroupEntries(cfg), + updatePrompt: ({ cfg }) => Boolean(cfg.channels?.msteams?.teams), + setPolicy: ({ cfg, policy }) => setMSTeamsGroupPolicy(cfg, policy), + resolveAllowlist: async ({ cfg, entries, prompter }) => + await resolveMSTeamsGroupAllowlist({ cfg, entries, prompter }), + applyAllowlist: ({ cfg, resolved }) => + setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), +}; + const msteamsDmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, @@ -290,96 +379,10 @@ export const msteamsSetupWizard: ChannelSetupWizard = { }; } - const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap( - ([teamKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - return [teamKey]; - } - return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); - }, - ); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "MS Teams channels", - currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist", - currentEntries, - placeholder: "Team Name/Channel Name, teamId/conversationId", - updatePrompt: Boolean(next.channels?.msteams?.teams), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMSTeamsGroupPolicy(next, accessConfig.policy); - } else { - let entries = accessConfig.entries - .map((entry) => parseMSTeamsTeamEntry(entry)) - .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; - if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { - try { - const resolvedEntries = await resolveMSTeamsChannelAllowlist({ - cfg: next, - entries: accessConfig.entries, - }); - const resolvedChannels = resolvedEntries.filter( - (entry) => entry.resolved && entry.teamId && entry.channelId, - ); - const resolvedTeams = resolvedEntries.filter( - (entry) => entry.resolved && entry.teamId && !entry.channelId, - ); - const unresolved = resolvedEntries - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - - entries = [ - ...resolvedChannels.map((entry) => ({ - teamKey: entry.teamId as string, - channelKey: entry.channelId as string, - })), - ...resolvedTeams.map((entry) => ({ - teamKey: entry.teamId as string, - })), - ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean), - ] as Array<{ teamKey: string; channelKey?: string }>; - - if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) { - const summary: string[] = []; - if (resolvedChannels.length > 0) { - summary.push( - `Resolved channels: ${resolvedChannels - .map((entry) => entry.channelId) - .filter(Boolean) - .join(", ")}`, - ); - } - if (resolvedTeams.length > 0) { - summary.push( - `Resolved teams: ${resolvedTeams - .map((entry) => entry.teamId) - .filter(Boolean) - .join(", ")}`, - ); - } - if (unresolved.length > 0) { - summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); - } - await prompter.note(summary.join("\n"), "MS Teams channels"); - } - } catch (err) { - await prompter.note( - `Channel lookup failed; keeping entries as typed. ${String(err)}`, - "MS Teams channels", - ); - } - } - next = setMSTeamsGroupPolicy(next, "allowlist"); - next = setMSTeamsTeamsAllowlist(next, entries); - } - } - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, dmPolicy: msteamsDmPolicy, + groupAccess: msteamsGroupAccess, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 0cf7903e6d4..c30f0134009 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -455,7 +455,7 @@ export function createSlackSetupWizardProxy( }) => { try { const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess) { + if (!wizard.groupAccess?.resolveAllowlist) { return entries; } return await wizard.groupAccess.resolveAllowlist({ diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 776644a2d23..bff81f47fff 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -3,7 +3,6 @@ */ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -228,6 +227,26 @@ function setTwitchAccessControl( }); } +function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" { + const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + if (account?.allowedRoles?.includes("all")) { + return "open"; + } + if (account?.allowedRoles?.includes("moderator")) { + return "allowlist"; + } + return "disabled"; +} + +function setTwitchGroupPolicy( + cfg: OpenClawConfig, + policy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + const allowedRoles: TwitchRole[] = + policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : []; + return setTwitchAccessControl(cfg, allowedRoles, true); +} + const twitchDmPolicy: ChannelOnboardingDmPolicy = { label: "Twitch", channel, @@ -270,6 +289,24 @@ const twitchDmPolicy: ChannelOnboardingDmPolicy = { }, }; +const twitchGroupAccess: NonNullable = { + label: "Twitch chat", + placeholder: "", + skipAllowlistEntries: true, + currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg as OpenClawConfig), + currentEntries: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account?.allowFrom ?? []; + }, + updatePrompt: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length); + }, + setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg as OpenClawConfig, policy), + resolveAllowlist: async () => [], + applyAllowlist: ({ cfg }) => cfg as OpenClawConfig, +}; + export const twitchSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg }) => @@ -342,37 +379,10 @@ export const twitchSetupWizard: ChannelSetupWizard = { ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - if (!account?.allowFrom || account.allowFrom.length === 0) { - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Twitch chat", - currentPolicy: account?.allowedRoles?.includes("all") - ? "open" - : account?.allowedRoles?.includes("moderator") - ? "allowlist" - : "disabled", - currentEntries: [], - placeholder: "", - updatePrompt: false, - }); - - if (accessConfig) { - const allowedRoles: TwitchRole[] = - accessConfig.policy === "open" - ? ["all"] - : accessConfig.policy === "allowlist" - ? ["moderator", "vip"] - : []; - - return { - cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), - }; - } - } - return { cfg: cfgWithAllowFrom }; }, dmPolicy: twitchDmPolicy, + groupAccess: twitchGroupAccess, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record diff --git a/src/channels/plugins/onboarding/channel-access-configure.test.ts b/src/channels/plugins/setup-group-access-configure.test.ts similarity index 77% rename from src/channels/plugins/onboarding/channel-access-configure.test.ts rename to src/channels/plugins/setup-group-access-configure.test.ts index aba8f05ea95..bb3b0307501 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.test.ts +++ b/src/channels/plugins/setup-group-access-configure.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; function createPrompter(params: { confirm: boolean; policy?: ChannelAccessPolicy; text?: string }) { return { @@ -89,6 +89,41 @@ describe("configureChannelAccessWithAllowlist", () => { expect(applyAllowlist).not.toHaveBeenCalled(); }); + it("supports allowlist policies without prompting for entries", async () => { + const cfg: OpenClawConfig = {}; + const prompter = createPrompter({ + confirm: true, + policy: "allowlist", + }); + const setPolicy = vi.fn( + (next: OpenClawConfig, policy: ChannelAccessPolicy): OpenClawConfig => ({ + ...next, + channels: { twitch: { groupPolicy: policy } }, + }), + ); + const resolveAllowlist = vi.fn(async () => ["ignored"]); + const applyAllowlist = vi.fn((params: { cfg: OpenClawConfig }) => params.cfg); + + const next = await configureChannelAccessWithAllowlist({ + cfg, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + currentPolicy: "disabled", + currentEntries: [], + placeholder: "", + updatePrompt: false, + skipAllowlistEntries: true, + setPolicy, + resolveAllowlist, + applyAllowlist, + }); + + expect(next.channels).toEqual({ twitch: { groupPolicy: "allowlist" } }); + expect(resolveAllowlist).not.toHaveBeenCalled(); + expect(applyAllowlist).not.toHaveBeenCalled(); + }); + it("resolves allowlist entries and applies them after forcing allowlist policy", async () => { const cfg: OpenClawConfig = {}; const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access-configure.ts b/src/channels/plugins/setup-group-access-configure.ts similarity index 65% rename from src/channels/plugins/onboarding/channel-access-configure.ts rename to src/channels/plugins/setup-group-access-configure.ts index 200efce5811..26b07f9cf99 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.ts +++ b/src/channels/plugins/setup-group-access-configure.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./setup-group-access.js"; export async function configureChannelAccessWithAllowlist(params: { cfg: OpenClawConfig; @@ -10,9 +10,10 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: string[]; placeholder: string; updatePrompt: boolean; + skipAllowlistEntries?: boolean; setPolicy: (cfg: OpenClawConfig, policy: ChannelAccessPolicy) => OpenClawConfig; - resolveAllowlist: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; - applyAllowlist: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; + resolveAllowlist?: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; + applyAllowlist?: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; }): Promise { let next = params.cfg; const accessConfig = await promptChannelAccessConfig({ @@ -22,6 +23,7 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: params.currentEntries, placeholder: params.placeholder, updatePrompt: params.updatePrompt, + skipAllowlistEntries: params.skipAllowlistEntries, }); if (!accessConfig) { return next; @@ -29,6 +31,9 @@ export async function configureChannelAccessWithAllowlist(params: { if (accessConfig.policy !== "allowlist") { return params.setPolicy(next, accessConfig.policy); } + if (params.skipAllowlistEntries || !params.resolveAllowlist || !params.applyAllowlist) { + return params.setPolicy(next, "allowlist"); + } const resolved = await params.resolveAllowlist({ cfg: next, entries: accessConfig.entries, diff --git a/src/channels/plugins/onboarding/channel-access.test.ts b/src/channels/plugins/setup-group-access.test.ts similarity index 84% rename from src/channels/plugins/onboarding/channel-access.test.ts rename to src/channels/plugins/setup-group-access.test.ts index 0e5b2ba6651..a19ed348015 100644 --- a/src/channels/plugins/onboarding/channel-access.test.ts +++ b/src/channels/plugins/setup-group-access.test.ts @@ -5,7 +5,7 @@ import { promptChannelAccessConfig, promptChannelAllowlist, promptChannelAccessPolicy, -} from "./channel-access.js"; +} from "./setup-group-access.js"; function createPrompter(params?: { confirm?: (options: { message: string; initialValue: boolean }) => Promise; @@ -83,6 +83,27 @@ describe("promptChannelAccessPolicy", () => { }); }); +describe("promptChannelAccessConfig", () => { + it("skips the allowlist text prompt when entries are policy-only", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "allowlist", + text: async () => { + throw new Error("text prompt should not run"); + }, + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + skipAllowlistEntries: true, + }); + + expect(result).toEqual({ policy: "allowlist", entries: [] }); + }); +}); + describe("promptChannelAccessConfig", () => { it("returns null when user skips configuration", async () => { const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/setup-group-access.ts similarity index 92% rename from src/channels/plugins/onboarding/channel-access.ts rename to src/channels/plugins/setup-group-access.ts index ef86b37f336..a757816e9ec 100644 --- a/src/channels/plugins/onboarding/channel-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,5 +1,5 @@ -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { splitOnboardingEntries } from "./helpers.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { splitOnboardingEntries } from "./onboarding/helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; @@ -64,6 +64,7 @@ export async function promptChannelAccessConfig(params: { placeholder?: string; allowOpen?: boolean; allowDisabled?: boolean; + skipAllowlistEntries?: boolean; defaultPrompt?: boolean; updatePrompt?: boolean; }): Promise<{ policy: ChannelAccessPolicy; entries: string[] } | null> { @@ -88,6 +89,9 @@ export async function promptChannelAccessConfig(params: { if (policy !== "allowlist") { return { policy, entries: [] }; } + if (params.skipAllowlistEntries) { + return { policy, entries: [] }; + } const entries = await promptChannelAllowlist({ prompter: params.prompter, label: params.label, diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 9f4f1fdb5cc..2d4896dd733 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -8,14 +8,14 @@ import type { ChannelOnboardingStatus, ChannelOnboardingStatusContext, } from "./onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "./onboarding/channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./onboarding/channel-access.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, runSingleChannelSecretStep, splitOnboardingEntries, } from "./onboarding/helpers.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelSetupInput } from "./types.core.js"; import type { ChannelPlugin } from "./types.js"; @@ -184,6 +184,7 @@ export type ChannelSetupWizardGroupAccess = { placeholder: string; helpTitle?: string; helpLines?: string[]; + skipAllowlistEntries?: boolean; currentPolicy: (params: { cfg: OpenClawConfig; accountId: string }) => ChannelAccessPolicy; currentEntries: (params: { cfg: OpenClawConfig; accountId: string }) => string[]; updatePrompt: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; @@ -192,14 +193,14 @@ export type ChannelSetupWizardGroupAccess = { accountId: string; policy: ChannelAccessPolicy; }) => OpenClawConfig; - resolveAllowlist: (params: { + resolveAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; entries: string[]; prompter: Pick; }) => Promise; - applyAllowlist: (params: { + applyAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; resolved: unknown; @@ -757,26 +758,31 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { currentEntries: access.currentEntries({ cfg: next, accountId }), placeholder: access.placeholder, updatePrompt: access.updatePrompt({ cfg: next, accountId }), + skipAllowlistEntries: access.skipAllowlistEntries, setPolicy: (currentCfg, policy) => access.setPolicy({ cfg: currentCfg, accountId, policy, }), - resolveAllowlist: async ({ cfg: currentCfg, entries }) => - await access.resolveAllowlist({ - cfg: currentCfg, - accountId, - credentialValues, - entries, - prompter, - }), - applyAllowlist: ({ cfg: currentCfg, resolved }) => - access.applyAllowlist({ - cfg: currentCfg, - accountId, - resolved, - }), + resolveAllowlist: access.resolveAllowlist + ? async ({ cfg: currentCfg, entries }) => + await access.resolveAllowlist!({ + cfg: currentCfg, + accountId, + credentialValues, + entries, + prompter, + }) + : undefined, + applyAllowlist: access.applyAllowlist + ? ({ cfg: currentCfg, resolved }) => + access.applyAllowlist!({ + cfg: currentCfg, + accountId, + resolved, + }) + : undefined, }); } diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 464af58776b..42ad2eb032f 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -27,7 +27,6 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, splitOnboardingEntries, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 472c46ea2e5..c74aab071ca 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -15,7 +15,6 @@ export { } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, - promptAccountId, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index f1415103398..291834b9648 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -3,7 +3,6 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 307ea5f16f5..9f680ce6b0e 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -15,7 +15,6 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 3ad3ca47549..5dba9c0aa77 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -14,7 +14,6 @@ export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { From 46482a283a250aecbd45c5ef6f19e2a41e26effb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:52:28 -0700 Subject: [PATCH 073/331] feat: add nostr setup and unify channel setup discovery --- docs/channels/nostr.md | 9 + docs/cli/channels.md | 3 +- extensions/nostr/src/channel.ts | 3 + extensions/nostr/src/setup-surface.test.ts | 67 ++++ extensions/nostr/src/setup-surface.ts | 297 ++++++++++++++++++ scripts/lib/plugin-sdk-entries.mjs | 48 +-- scripts/lib/plugin-sdk-entrypoints.json | 45 +++ src/channels/plugins/types.core.ts | 2 + src/cli/channels-cli.ts | 4 + src/commands/channel-setup/discovery.ts | 108 +++++++ .../{onboarding => channel-setup}/registry.ts | 54 +++- src/commands/channel-test-helpers.ts | 2 +- src/commands/channels.add.test.ts | 96 ++++++ src/commands/channels/add.ts | 43 ++- src/commands/onboard-channels.e2e.test.ts | 121 +++++++ src/commands/onboard-channels.ts | 102 +++--- src/plugin-sdk/entrypoints.ts | 36 +++ src/plugin-sdk/index.test.ts | 2 +- src/plugin-sdk/nostr.ts | 2 + src/plugin-sdk/subpaths.test.ts | 8 +- 20 files changed, 922 insertions(+), 130 deletions(-) create mode 100644 extensions/nostr/src/setup-surface.test.ts create mode 100644 extensions/nostr/src/setup-surface.ts create mode 100644 scripts/lib/plugin-sdk-entrypoints.json create mode 100644 src/commands/channel-setup/discovery.ts rename src/commands/{onboarding => channel-setup}/registry.ts (54%) create mode 100644 src/plugin-sdk/entrypoints.ts diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 3368933d6c4..760704b589f 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -40,6 +40,15 @@ openclaw plugins install --link /extensions/nostr Restart the Gateway after installing or enabling plugins. +### Non-interactive setup + +```bash +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net" +``` + +Use `--use-env` to keep `NOSTR_PRIVATE_KEY` in the environment instead of storing the key in config. + ## Quick setup 1. Generate a Nostr keypair (if needed): diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 654fbef5fa9..96b9ef33f8c 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -30,10 +30,11 @@ openclaw channels logs --channel all ```bash openclaw channels add --channel telegram --token +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" openclaw channels remove --channel telegram --delete ``` -Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc). +Tip: `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc). When you run `openclaw channels add` without flags, the interactive wizard can prompt: diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 937c698bd47..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -17,6 +17,7 @@ import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; import { getNostrRuntime } from "./runtime.js"; +import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, @@ -47,6 +48,8 @@ export const nostrPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), + setup: nostrSetupAdapter, + setupWizard: nostrSetupWizard, config: { listAccountIds: (cfg) => listNostrAccountIds(cfg), diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts new file mode 100644 index 00000000000..c9c62e14c9a --- /dev/null +++ b/extensions/nostr/src/setup-surface.test.ts @@ -0,0 +1,67 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { nostrPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const nostrConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: nostrPlugin, + wizard: nostrPlugin.setupWizard!, +}); + +describe("nostr setup wizard", () => { + it("configures a private key and relay URLs", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Nostr private key (nsec... or hex)") { + return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + } + if (message === "Relay URLs (comma-separated, optional)") { + return "wss://relay.damus.io, wss://relay.primal.net"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await nostrConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.nostr?.enabled).toBe(true); + expect(result.cfg.channels?.nostr?.privateKey).toBe( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + expect(result.cfg.channels?.nostr?.relays).toEqual([ + "wss://relay.damus.io", + "wss://relay.primal.net", + ]); + }); +}); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts new file mode 100644 index 00000000000..d58a4c4fbdc --- /dev/null +++ b/extensions/nostr/src/setup-surface.ts @@ -0,0 +1,297 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + parseOnboardingEntriesWithParser, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { resolveNostrAccount } from "./types.js"; + +const channel = "nostr" as const; + +const NOSTR_SETUP_HELP_LINES = [ + "Use a Nostr private key in nsec or 64-character hex format.", + "Relay URLs are optional. Leave blank to keep the default relay set.", + "Env vars supported: NOSTR_PRIVATE_KEY (default account only).", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +const NOSTR_ALLOW_FROM_HELP_LINES = [ + "Allowlist Nostr DMs by npub or hex pubkey.", + "Examples:", + "- npub1...", + "- nostr:npub1...", + "- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +function patchNostrConfig(params: { + cfg: OpenClawConfig; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const existing = (params.cfg.channels?.nostr ?? {}) as Record; + const nextNostr = { ...existing }; + for (const field of params.clearFields ?? []) { + delete nextNostr[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + nostr: { + ...nextNostr, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; +} + +function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }); +} + +function parseRelayUrls(raw: string): { relays: string[]; error?: string } { + const entries = splitOnboardingEntries(raw); + const relays: string[] = []; + for (const entry of entries) { + try { + const parsed = new URL(entry); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` }; + } + } catch { + return { relays: [], error: `Invalid relay URL: ${entry}` }; + } + relays.push(entry); + } + return { relays: [...new Set(relays)] }; +} + +function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesWithParser(raw, (entry) => { + const cleaned = entry.replace(/^nostr:/i, "").trim(); + try { + return { value: normalizePubkey(cleaned) }; + } catch { + return { error: `Invalid Nostr pubkey: ${entry}` }; + } + }); +} + +async function promptNostrAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.nostr?.allowFrom ?? []; + await params.prompter.note(NOSTR_ALLOW_FROM_HELP_LINES.join("\n"), "Nostr allowlist"); + const entry = await params.prompter.text({ + message: "Nostr allowFrom", + placeholder: "npub1..., 0123abcd...", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + return parseNostrAllowFrom(raw).error; + }, + }); + const parsed = parseNostrAllowFrom(String(entry)); + return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); +} + +const nostrDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nostr", + channel, + policyKey: "channels.nostr.dmPolicy", + allowFromKey: "channels.nostr.allowFrom", + getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy), + promptAllowFrom: promptNostrAllowFrom, +}; + +export const nostrSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountName: ({ cfg, name }) => + patchNostrConfig({ + cfg, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + if (!typedInput.useEnv) { + const privateKey = typedInput.privateKey?.trim(); + if (!privateKey) { + return "Nostr requires --private-key or --use-env."; + } + try { + getPublicKeyFromPrivate(privateKey); + } catch { + return "Nostr private key must be valid nsec or 64-character hex."; + } + } + if (typedInput.relayUrls?.trim()) { + return parseRelayUrls(typedInput.relayUrls).error ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + const relayResult = typedInput.relayUrls?.trim() + ? parseRelayUrls(typedInput.relayUrls) + : { relays: [] }; + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: typedInput.useEnv ? ["privateKey"] : undefined, + patch: { + ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }), + ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}), + }, + }); + }, +}; + +export const nostrSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs private key", + configuredHint: "configured", + unconfiguredHint: "needs private key", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured, + resolveStatusLines: ({ cfg, configured }) => { + const account = resolveNostrAccount({ cfg }); + return [ + `Nostr: ${configured ? "configured" : "needs private key"}`, + `Relays: ${account.relays.length || DEFAULT_RELAYS.length}`, + ]; + }, + }, + introNote: { + title: "Nostr setup", + lines: NOSTR_SETUP_HELP_LINES, + }, + envShortcut: { + prompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && + !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), + apply: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + }, + credentials: [ + { + inputKey: "privateKey", + providerHint: channel, + credentialLabel: "private key", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + helpTitle: "Nostr private key", + helpLines: NOSTR_SETUP_HELP_LINES, + envPrompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + keepPrompt: "Nostr private key already configured. Keep it?", + inputPrompt: "Nostr private key (nsec... or hex)", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: Boolean(account.config.privateKey?.trim()), + resolvedValue: account.config.privateKey?.trim(), + envValue: process.env.NOSTR_PRIVATE_KEY?.trim(), + }; + }, + applyUseEnv: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + applySet: async ({ cfg, resolvedValue }) => + patchNostrConfig({ + cfg, + enabled: true, + patch: { privateKey: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "relayUrls", + message: "Relay URLs (comma-separated, optional)", + placeholder: DEFAULT_RELAYS.join(", "), + required: false, + applyEmptyValue: true, + helpTitle: "Nostr relays", + helpLines: ["Use ws:// or wss:// relay URLs.", "Leave blank to keep the default relay set."], + currentValue: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + const relays = + cfg.channels?.nostr?.relays && cfg.channels.nostr.relays.length > 0 ? account.relays : []; + return relays.join(", "); + }, + keepPrompt: (value) => `Relay URLs set (${value}). Keep them?`, + validate: ({ value }) => parseRelayUrls(value).error, + applySet: async ({ cfg, value }) => { + const relayResult = parseRelayUrls(value); + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: relayResult.relays.length > 0 ? undefined : ["relays"], + patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}, + }); + }, + }, + ], + dmPolicy: nostrDmPolicy, + disable: (cfg) => + patchNostrConfig({ + cfg, + patch: { enabled: false }, + }), +}; diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs index ba6c1a5c386..c2ce28484ae 100644 --- a/scripts/lib/plugin-sdk-entries.mjs +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -1,48 +1,6 @@ -export const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; +import pluginSdkEntryList from "./plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json new file mode 100644 index 00000000000..c42f27db5a1 --- /dev/null +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -0,0 +1,45 @@ +[ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue" +] diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fef8b010ca5..73600e47d5b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -21,6 +21,7 @@ export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => Chan export type ChannelSetupInput = { name?: string; token?: string; + privateKey?: string; tokenFile?: string; botToken?: string; appToken?: string; @@ -46,6 +47,7 @@ export type ChannelSetupInput = { initialSyncLimit?: number; ship?: string; url?: string; + relayUrls?: string; code?: string; groupChannels?: string[]; dmAllowlist?: string[]; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 3015ed1d42a..d2e7bf148f3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -14,6 +14,7 @@ const optionNamesAdd = [ "account", "name", "token", + "privateKey", "tokenFile", "botToken", "appToken", @@ -39,6 +40,7 @@ const optionNamesAdd = [ "initialSyncLimit", "ship", "url", + "relayUrls", "code", "groupChannels", "dmAllowlist", @@ -164,6 +166,7 @@ export function registerChannelsCli(program: Command) { .option("--account ", "Account id (default when omitted)") .option("--name ", "Display name for this account") .option("--token ", "Bot token (Telegram/Discord)") + .option("--private-key ", "Nostr private key (nsec... or hex)") .option("--token-file ", "Bot token file (Telegram)") .option("--bot-token ", "Slack bot token (xoxb-...)") .option("--app-token ", "Slack app token (xapp-...)") @@ -188,6 +191,7 @@ export function registerChannelsCli(program: Command) { .option("--initial-sync-limit ", "Matrix initial sync limit") .option("--ship ", "Tlon ship name (~sampel-palnet)") .option("--url ", "Tlon ship URL") + .option("--relay-urls ", "Nostr relay URLs (comma-separated)") .option("--code ", "Tlon login code") .option("--group-channels ", "Tlon group channels (comma-separated)") .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts new file mode 100644 index 00000000000..8ae5f16f800 --- /dev/null +++ b/src/commands/channel-setup/discovery.ts @@ -0,0 +1,108 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + listChannelPluginCatalogEntries, + type ChannelPluginCatalogEntry, +} from "../../channels/plugins/catalog.js"; +import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js"; +import { listChatChannels } from "../../channels/registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import type { ChannelChoice } from "../onboard-types.js"; + +type ChannelCatalogEntry = { + id: ChannelChoice; + meta: ChannelMeta; +}; + +export type ResolvedChannelSetupEntries = { + entries: ChannelCatalogEntry[]; + installedCatalogEntries: ChannelPluginCatalogEntry[]; + installableCatalogEntries: ChannelPluginCatalogEntry[]; + installedCatalogById: Map; + installableCatalogById: Map; +}; + +function resolveWorkspaceDir(cfg: OpenClawConfig, workspaceDir?: string): string | undefined { + return workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +export function listManifestInstalledChannelIds(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Set { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + return new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: params.env ?? process.env, + }).plugins.flatMap((plugin) => plugin.channels as ChannelChoice[]), + ); +} + +export function isCatalogChannelInstalled(params: { + cfg: OpenClawConfig; + entry: ChannelPluginCatalogEntry; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + return listManifestInstalledChannelIds(params).has(params.entry.id as ChannelChoice); +} + +export function resolveChannelSetupEntries(params: { + cfg: OpenClawConfig; + installedPlugins: ChannelPlugin[]; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ResolvedChannelSetupEntries { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + const manifestInstalledIds = listManifestInstalledChannelIds({ + cfg: params.cfg, + workspaceDir, + env: params.env, + }); + const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id)); + const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice), + ); + const installableCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice), + ); + + const metaById = new Map(); + for (const meta of listChatChannels()) { + metaById.set(meta.id, meta); + } + for (const plugin of params.installedPlugins) { + metaById.set(plugin.id, plugin.meta); + } + for (const entry of installedCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + for (const entry of installableCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + + return { + entries: Array.from(metaById, ([id, meta]) => ({ + id: id as ChannelChoice, + meta, + })), + installedCatalogEntries, + installableCatalogEntries, + installedCatalogById: new Map( + installedCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + installableCatalogById: new Map( + installableCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + }; +} diff --git a/src/commands/onboarding/registry.ts b/src/commands/channel-setup/registry.ts similarity index 54% rename from src/commands/onboarding/registry.ts rename to src/commands/channel-setup/registry.ts index 9d7711e3092..576d7e14b60 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -1,8 +1,29 @@ +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { linePlugin } from "../../../extensions/line/src/channel.js"; +import { signalPlugin } from "../../../extensions/signal/src/channel.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; -import type { ChannelOnboardingAdapter } from "./types.js"; +import type { ChannelOnboardingAdapter } from "../onboarding/types.js"; + +const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ + telegramPlugin, + whatsappPlugin, + discordPlugin, + ircPlugin, + googlechatPlugin, + slackPlugin, + signalPlugin, + imessagePlugin, + linePlugin, +]; const setupWizardAdapters = new WeakMap(); @@ -26,7 +47,12 @@ export function resolveChannelOnboardingAdapterForPlugin( const CHANNEL_ONBOARDING_ADAPTERS = () => { const adapters = new Map(); - for (const plugin of listChannelSetupPlugins()) { + const setupPlugins = listChannelSetupPlugins(); + const plugins = + setupPlugins.length > 0 + ? setupPlugins + : (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType); + for (const plugin of plugins) { const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; @@ -51,23 +77,23 @@ export async function loadBundledChannelOnboardingPlugin( ): Promise { switch (channel) { case "discord": - return (await import("../../../extensions/discord/setup-entry.js")).default - .plugin as ChannelPlugin; + return discordPlugin as ChannelPlugin; + case "googlechat": + return googlechatPlugin as ChannelPlugin; case "imessage": - return (await import("../../../extensions/imessage/setup-entry.js")).default - .plugin as ChannelPlugin; + return imessagePlugin as ChannelPlugin; + case "irc": + return ircPlugin as ChannelPlugin; + case "line": + return linePlugin as ChannelPlugin; case "signal": - return (await import("../../../extensions/signal/setup-entry.js")).default - .plugin as ChannelPlugin; + return signalPlugin as ChannelPlugin; case "slack": - return (await import("../../../extensions/slack/setup-entry.js")).default - .plugin as ChannelPlugin; + return slackPlugin as ChannelPlugin; case "telegram": - return (await import("../../../extensions/telegram/setup-entry.js")).default - .plugin as ChannelPlugin; + return telegramPlugin as ChannelPlugin; case "whatsapp": - return (await import("../../../extensions/whatsapp/setup-entry.js")).default - .plugin as ChannelPlugin; + return whatsappPlugin as ChannelPlugin; default: return undefined; } diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 2814f6bb5bd..97167228e7f 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,8 +6,8 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { getChannelOnboardingAdapter } from "./channel-setup/registry.js"; import type { ChannelChoice } from "./onboard-types.js"; -import { getChannelOnboardingAdapter } from "./onboarding/registry.js"; import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; type ChannelOnboardingAdapterPatch = Partial< diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 9f584494fba..fdb3e61f97d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -14,6 +14,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -22,6 +26,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -48,6 +60,11 @@ describe("channelsAddCommand", () => { runtime.exit.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + manifestRegistryMocks.loadPluginManifestRegistry.mockClear(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); vi.mocked(ensureOnboardingPluginInstalled).mockClear(); vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, @@ -171,6 +188,85 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("uses the installed external channel snapshot without reinstalling", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + const scopedMSTeamsPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, + }, + }, + })), + }, + }; + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); + + await channelsAddCommand( + { + channel: "msteams", + account: "default", + token: "tenant-installed", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + msteams: { + enabled: true, + tenantId: "tenant-installed", + }, + }, + }), + ); + }); + it("uses the installed plugin id when channel and plugin ids differ", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 88e1a245906..0c9b5b15e56 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -9,6 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -202,24 +203,32 @@ export async function channelsAddCommand( }; if (!channel && catalogEntry) { - const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); - const prompter = createClackPrompter(); const workspaceDir = resolveWorkspaceDir(); - const result = await ensureOnboardingPluginInstalled({ - cfg: nextConfig, - entry: catalogEntry, - prompter, - runtime, - workspaceDir, - }); - nextConfig = result.cfg; - if (!result.installed) { - return; + if ( + !isCatalogChannelInstalled({ + cfg: nextConfig, + entry: catalogEntry, + workspaceDir, + }) + ) { + const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); + const prompter = createClackPrompter(); + const result = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) { + return; + } + catalogEntry = { + ...catalogEntry, + ...(result.pluginId ? { pluginId: result.pluginId } : {}), + }; } - catalogEntry = { - ...catalogEntry, - ...(result.pluginId ? { pluginId: result.pluginId } : {}), - }; channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } @@ -251,6 +260,7 @@ export async function channelsAddCommand( const input: ChannelSetupInput = { name: opts.name, token: opts.token, + privateKey: opts.privateKey, tokenFile: opts.tokenFile, botToken: opts.botToken, appToken: opts.appToken, @@ -276,6 +286,7 @@ export async function channelsAddCommand( useEnv, ship: opts.ship, url: opts.url, + relayUrls: opts.relayUrls, code: opts.code, groupChannels, dmAllowlist, diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index c469f50a54e..0f2fb4c2e1e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -10,6 +10,7 @@ import { } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; import { + ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; @@ -19,6 +20,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn(), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter( { @@ -197,6 +202,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); @@ -205,6 +218,10 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as Record), + ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + installed: true, + })), // Allow tests to simulate an empty plugin registry during onboarding. loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()), reloadOnboardingPluginRegistry: vi.fn(() => {}), @@ -215,6 +232,16 @@ describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); catalogMocks.listChannelPluginCatalogEntries.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + vi.mocked(ensureOnboardingPluginInstalled).mockClear(); + vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear(); vi.mocked(reloadOnboardingPluginRegistry).mockClear(); }); @@ -404,6 +431,100 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("treats installed external plugin channels as installed without reinstall prompts", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + } satisfies ChannelPluginCatalogEntry, + ]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + registry.channelSetups.push({ + pluginId: "@openclaw/msteams-plugin", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "installed", + resolveConfigured: () => false, + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "installed", + }, + credentials: [], + }, + outbound: { deliveryMode: "direct" }, + }, + } as never); + } + return registry; + }, + ); + + let channelSelectionCount = 0; + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select a channel") { + channelSelectionCount += 1; + return channelSelectionCount === 1 ? "msteams" : "__done__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("uses scoped plugin accounts when disabling a configured external channel", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); const setAccountEnabled = vi.fn( diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index c70fbde04ab..4fa8807d55e 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,8 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -16,11 +17,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; +import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, @@ -29,7 +30,7 @@ import { import { loadBundledChannelOnboardingPlugin, resolveChannelOnboardingAdapterForPlugin, -} from "./onboarding/registry.js"; +} from "./channel-setup/registry.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingConfiguredResult, @@ -44,6 +45,7 @@ type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; + installedCatalogEntries: ReturnType; statusByChannel: Map; statusLines: string[]; }; @@ -125,15 +127,11 @@ async function collectChannelStatus(params: { }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); - const installedChannelIds = new Set( - loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir, - env: process.env, - }).plugins.flatMap((plugin) => plugin.channels), - ); - const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const { installedCatalogEntries, installableCatalogEntries } = resolveChannelSetupEntries({ + cfg: params.cfg, + installedPlugins, + workspaceDir, + }); const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => @@ -167,8 +165,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const discoveredPluginStatuses = allCatalogEntries - .filter((entry) => installedChannelIds.has(entry.id)) + const discoveredPluginStatuses = installedCatalogEntries .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) .map((entry) => { const configured = isChannelConfigured(params.cfg, entry.id); @@ -189,7 +186,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const catalogStatuses = catalogEntries.map((entry) => ({ + const catalogStatuses = installableCatalogEntries.map((entry) => ({ channel: entry.id, configured: false, statusLines: [`${entry.meta.label}: install plugin to enable`], @@ -206,7 +203,8 @@ async function collectChannelStatus(params: { const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { installedPlugins, - catalogEntries, + catalogEntries: installableCatalogEntries, + installedCatalogEntries, statusByChannel: mergedStatusByChannel, statusLines, }; @@ -428,14 +426,19 @@ export async function setupChannels( } preloadConfiguredExternalPlugins(); - const { installedPlugins, catalogEntries, statusByChannel, statusLines } = - await collectChannelStatus({ - cfg: next, - options, - accountOverrides, - installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleOnboardingAdapter, - }); + const { + installedPlugins, + catalogEntries, + installedCatalogEntries, + statusByChannel, + statusLines, + } = await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleOnboardingAdapter, + }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -465,6 +468,13 @@ export async function setupChannels( label: plugin.meta.label, blurb: plugin.meta.blurb, })), + ...installedCatalogEntries + .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) + .map((entry) => ({ + id: entry.id as ChannelChoice, + label: entry.meta.label, + blurb: entry.meta.blurb, + })), ...catalogEntries .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) .map((entry) => ({ @@ -542,33 +552,15 @@ export async function setupChannels( }); const getChannelEntries = () => { - const core = listChatChannels(); - const installed = listVisibleInstalledPlugins(); - const installedIds = new Set(installed.map((plugin) => plugin.id)); - const workspaceDir = resolveWorkspaceDir(); - const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), - ); - const metaById = new Map(); - for (const meta of core) { - metaById.set(meta.id, meta); - } - for (const plugin of installed) { - metaById.set(plugin.id, plugin.meta); - } - for (const entry of catalog) { - if (!metaById.has(entry.id)) { - metaById.set(entry.id, entry.meta); - } - } - const entries = Array.from(metaById, ([id, meta]) => ({ - id: id as ChannelChoice, - meta, - })); + const resolved = resolveChannelSetupEntries({ + cfg: next, + installedPlugins: listVisibleInstalledPlugins(), + workspaceDir: resolveWorkspaceDir(), + }); return { - entries, - catalog, - catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])), + entries: resolved.entries, + catalogById: resolved.installableCatalogById, + installedCatalogById: resolved.installedCatalogById, }; }; @@ -746,8 +738,9 @@ export async function setupChannels( }; const handleChannelChoice = async (channel: ChannelChoice) => { - const { catalogById } = getChannelEntries(); + const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); + const installedCatalogEntry = installedCatalogById.get(channel); if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const result = await ensureOnboardingPluginInstalled({ @@ -763,6 +756,13 @@ export async function setupChannels( } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); + } else if (installedCatalogEntry) { + const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); + if (!plugin) { + await prompter.note(`${channel} plugin not available.`, "Channel setup"); + return; + } + await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts new file mode 100644 index 00000000000..04b7902de9e --- /dev/null +++ b/src/plugin-sdk/entrypoints.ts @@ -0,0 +1,36 @@ +import pluginSdkEntryList from "../../scripts/lib/plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; + +export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); + +export function buildPluginSdkEntrySources() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ); +} + +export function buildPluginSdkSpecifiers() { + return pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, + ); +} + +export function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + types: `./dist/plugin-sdk/${entry}.d.ts`, + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + +export function listPluginSdkDistArtifacts() { + return pluginSdkEntrypoints.flatMap((entry) => [ + `dist/plugin-sdk/${entry}.js`, + `dist/plugin-sdk/${entry}.d.ts`, + ]); +} diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index dd99550b122..d634f80ce66 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -9,7 +9,7 @@ import { buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, -} from "../../scripts/lib/plugin-sdk-entries.mjs"; +} from "./entrypoints.js"; import * as sdk from "./index.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 381e5e71a8a..a2997c5702c 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/nostr. export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -18,3 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6e4b942b9a9..a483e5aaf30 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -4,12 +4,13 @@ import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; +import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; -import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; +import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -93,6 +94,11 @@ describe("plugin-sdk subpath exports", () => { expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object"); }); + it("exports Nostr helpers", () => { + expect(typeof nostrSdk.nostrSetupWizard).toBe("object"); + expect(typeof nostrSdk.nostrSetupAdapter).toBe("object"); + }); + it("exports Google Chat helpers", async () => { const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); From bc6ca4940b3f27e6c958fad91cf150016a361296 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:57:10 -0700 Subject: [PATCH 074/331] fix: drop duplicate channel setup import --- src/commands/onboard-channels.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 4fa8807d55e..103f81cbff9 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -6,7 +6,6 @@ import { listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -22,15 +21,15 @@ import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; +import { + loadBundledChannelOnboardingPlugin, + resolveChannelOnboardingAdapterForPlugin, +} from "./channel-setup/registry.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; -import { - loadBundledChannelOnboardingPlugin, - resolveChannelOnboardingAdapterForPlugin, -} from "./channel-setup/registry.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingConfiguredResult, From d8b927ee6a9f5e1a4d2c262630a4e637d9e27427 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:02:05 -0700 Subject: [PATCH 075/331] feat: add openshell sandbox backend --- CHANGELOG.md | 1 + extensions/openshell/index.ts | 30 ++ extensions/openshell/openclaw.plugin.json | 99 ++++ extensions/openshell/package.json | 12 + extensions/openshell/src/backend.test.ts | 117 +++++ extensions/openshell/src/backend.ts | 445 ++++++++++++++++++ extensions/openshell/src/cli.test.ts | 37 ++ extensions/openshell/src/cli.ts | 166 +++++++ extensions/openshell/src/config.test.ts | 28 ++ extensions/openshell/src/config.ts | 225 +++++++++ extensions/openshell/src/fs-bridge.test.ts | 88 ++++ extensions/openshell/src/fs-bridge.ts | 336 +++++++++++++ extensions/openshell/src/mirror.ts | 47 ++ src/agents/bash-tools.exec-runtime.ts | 28 +- src/agents/bash-tools.shared.ts | 13 + ...ed-runner.buildembeddedsandboxinfo.test.ts | 3 + src/agents/pi-tools-agent-config.test.ts | 3 + src/agents/pi-tools.ts | 4 +- src/agents/sandbox-merge.test.ts | 5 + .../sandbox.resolveSandboxContext.test.ts | 42 ++ src/agents/sandbox.ts | 20 + src/agents/sandbox/backend.test.ts | 39 ++ src/agents/sandbox/backend.ts | 148 ++++++ src/agents/sandbox/browser.create.test.ts | 1 + src/agents/sandbox/config.ts | 1 + src/agents/sandbox/context.ts | 53 ++- src/agents/sandbox/docker-backend.ts | 130 +++++ .../docker.config-hash-recreate.test.ts | 1 + src/agents/sandbox/docker.ts | 3 + src/agents/sandbox/fs-bridge.ts | 36 +- src/agents/sandbox/manage.ts | 114 +++-- src/agents/sandbox/prune.ts | 41 +- src/agents/sandbox/registry.test.ts | 22 + src/agents/sandbox/registry.ts | 26 +- src/agents/sandbox/test-fixtures.ts | 3 + src/agents/sandbox/types.ts | 6 + .../test-helpers/pi-tools-sandbox-context.ts | 3 + src/commands/doctor-sandbox.ts | 15 + src/commands/sandbox-display.ts | 27 +- src/commands/sandbox.test.ts | 16 +- src/commands/sandbox.ts | 4 +- src/config/types.agents-shared.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/plugin-sdk/core.ts | 23 + 44 files changed, 2343 insertions(+), 121 deletions(-) create mode 100644 extensions/openshell/index.ts create mode 100644 extensions/openshell/openclaw.plugin.json create mode 100644 extensions/openshell/package.json create mode 100644 extensions/openshell/src/backend.test.ts create mode 100644 extensions/openshell/src/backend.ts create mode 100644 extensions/openshell/src/cli.test.ts create mode 100644 extensions/openshell/src/cli.ts create mode 100644 extensions/openshell/src/config.test.ts create mode 100644 extensions/openshell/src/config.ts create mode 100644 extensions/openshell/src/fs-bridge.test.ts create mode 100644 extensions/openshell/src/fs-bridge.ts create mode 100644 extensions/openshell/src/mirror.ts create mode 100644 src/agents/sandbox/backend.test.ts create mode 100644 src/agents/sandbox/backend.ts create mode 100644 src/agents/sandbox/docker-backend.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 232cbb167a1..98208595e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only. ### Fixes diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts new file mode 100644 index 00000000000..910abe31b44 --- /dev/null +++ b/extensions/openshell/index.ts @@ -0,0 +1,30 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; +import { + createOpenShellSandboxBackendFactory, + createOpenShellSandboxBackendManager, +} from "./src/backend.js"; +import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js"; + +const plugin = { + id: "openshell", + name: "OpenShell Sandbox", + description: "OpenShell-backed sandbox runtime for agent exec and file tools.", + configSchema: createOpenShellPluginConfigSchema(), + register(api: OpenClawPluginApi) { + if (api.registrationMode !== "full") { + return; + } + const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig); + registerSandboxBackend("openshell", { + factory: createOpenShellSandboxBackendFactory({ + pluginConfig, + }), + manager: createOpenShellSandboxBackendManager({ + pluginConfig, + }), + }); + }, +}; + +export default plugin; diff --git a/extensions/openshell/openclaw.plugin.json b/extensions/openshell/openclaw.plugin.json new file mode 100644 index 00000000000..cf3f9ad5579 --- /dev/null +++ b/extensions/openshell/openclaw.plugin.json @@ -0,0 +1,99 @@ +{ + "id": "openshell", + "name": "OpenShell Sandbox", + "description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + }, + "gateway": { + "type": "string" + }, + "gatewayEndpoint": { + "type": "string" + }, + "from": { + "type": "string" + }, + "policy": { + "type": "string" + }, + "providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "gpu": { + "type": "boolean" + }, + "autoProviders": { + "type": "boolean" + }, + "remoteWorkspaceDir": { + "type": "string" + }, + "remoteAgentWorkspaceDir": { + "type": "string" + }, + "timeoutSeconds": { + "type": "number", + "minimum": 1 + } + } + }, + "uiHints": { + "command": { + "label": "OpenShell Command", + "help": "Path or command name for the openshell CLI." + }, + "gateway": { + "label": "Gateway Name", + "help": "Optional OpenShell gateway name passed as --gateway." + }, + "gatewayEndpoint": { + "label": "Gateway Endpoint", + "help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint." + }, + "from": { + "label": "Sandbox Source", + "help": "OpenShell sandbox source for first-time create. Defaults to openclaw." + }, + "policy": { + "label": "Policy File", + "help": "Optional path to a custom OpenShell sandbox policy YAML." + }, + "providers": { + "label": "Providers", + "help": "Provider names to attach when a sandbox is created." + }, + "gpu": { + "label": "GPU", + "help": "Request GPU resources when creating the sandbox.", + "advanced": true + }, + "autoProviders": { + "label": "Auto-create Providers", + "help": "When enabled, pass --auto-providers during sandbox create.", + "advanced": true + }, + "remoteWorkspaceDir": { + "label": "Remote Workspace Dir", + "help": "Primary writable workspace inside the OpenShell sandbox.", + "advanced": true + }, + "remoteAgentWorkspaceDir": { + "label": "Remote Agent Dir", + "help": "Mirror path for the real agent workspace when workspaceAccess is read-only.", + "advanced": true + }, + "timeoutSeconds": { + "label": "Command Timeout Seconds", + "help": "Timeout for openshell CLI operations such as create/upload/download.", + "advanced": true + } + } +} diff --git a/extensions/openshell/package.json b/extensions/openshell/package.json new file mode 100644 index 00000000000..464c749ea34 --- /dev/null +++ b/extensions/openshell/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openshell-sandbox", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenShell sandbox backend", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts new file mode 100644 index 00000000000..2999599c648 --- /dev/null +++ b/extensions/openshell/src/backend.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const cliMocks = vi.hoisted(() => ({ + runOpenShellCli: vi.fn(), +})); + +vi.mock("./cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOpenShellCli: cliMocks.runOpenShellCli, + }; +}); + +import { createOpenShellSandboxBackendManager } from "./backend.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell backend manager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checks runtime status with config override from OpenClaw config", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "{}", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + from: "openclaw", + }), + }); + + const result = await manager.describeRuntime({ + entry: { + containerName: "openclaw-session-1234", + backendId: "openshell", + runtimeLabel: "openclaw-session-1234", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "custom-source", + configLabelKind: "Source", + }, + config: { + plugins: { + entries: { + openshell: { + enabled: true, + config: { + command: "openshell", + from: "custom-source", + }, + }, + }, + }, + }, + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "custom-source", + configLabelMatch: true, + }); + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-1234", + config: expect.objectContaining({ + from: "custom-source", + }), + }), + args: ["sandbox", "get", "openclaw-session-1234"], + }); + }); + + it("removes runtimes via openshell sandbox delete", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }); + + await manager.removeRuntime({ + entry: { + containerName: "openclaw-session-5678", + backendId: "openshell", + runtimeLabel: "openclaw-session-5678", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw", + configLabelKind: "Source", + }, + }); + + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-5678", + config: expect.objectContaining({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }), + args: ["sandbox", "delete", "openclaw-session-5678"], + }); + }); +}); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts new file mode 100644 index 00000000000..48f730946d4 --- /dev/null +++ b/extensions/openshell/src/backend.ts @@ -0,0 +1,445 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + CreateSandboxBackendParams, + OpenClawConfig, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendManager, +} from "openclaw/plugin-sdk/core"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + createOpenShellSshSession, + disposeOpenShellSshSession, + runOpenShellCli, + runOpenShellSshCommand, + type OpenShellExecContext, + type OpenShellSshSession, +} from "./cli.js"; +import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; +import { replaceDirectoryContents } from "./mirror.js"; + +type CreateOpenShellSandboxBackendFactoryParams = { + pluginConfig: ResolvedOpenShellPluginConfig; +}; + +type PendingExec = { + sshSession: OpenShellSshSession; +}; + +export type OpenShellSandboxBackend = SandboxBackendHandle & { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; +}; + +export function createOpenShellSandboxBackendFactory( + params: CreateOpenShellSandboxBackendFactoryParams, +): SandboxBackendFactory { + return async (createParams) => + await createOpenShellSandboxBackend({ + ...params, + createParams, + }); +} + +export function createOpenShellSandboxBackendManager(params: { + pluginConfig: ResolvedOpenShellPluginConfig; +}): SandboxBackendManager { + return { + async describeRuntime({ entry, config }) { + const execContext: OpenShellExecContext = { + config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig), + sandboxName: entry.containerName, + }; + const result = await runOpenShellCli({ + context: execContext, + args: ["sandbox", "get", entry.containerName], + }); + const configuredSource = execContext.config.from; + return { + running: result.code === 0, + actualConfigLabel: entry.image, + configLabelMatch: entry.image === configuredSource, + }; + }, + async removeRuntime({ entry }) { + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName: entry.containerName, + }; + await runOpenShellCli({ + context: execContext, + args: ["sandbox", "delete", entry.containerName], + }); + }, + }; +} + +async function createOpenShellSandboxBackend(params: { + pluginConfig: ResolvedOpenShellPluginConfig; + createParams: CreateSandboxBackendParams; +}): Promise { + if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds."); + } + + const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey); + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName, + }; + const impl = new OpenShellSandboxBackendImpl({ + createParams: params.createParams, + execContext, + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + }); + + return { + id: "openshell", + runtimeId: sandboxName, + runtimeLabel: sandboxName, + workdir: params.pluginConfig.remoteWorkspaceDir, + env: params.createParams.cfg.docker.env, + configLabel: params.pluginConfig.from, + configLabelKind: "Source", + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await impl.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await impl.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await impl.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: impl.asHandle(), + }), + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await impl.syncLocalPathToRemote(localPath, remotePath), + }; +} + +class OpenShellSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + execContext: OpenShellExecContext; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + }, + ) {} + + asHandle(): OpenShellSandboxBackend { + const self = this; + return { + id: "openshell", + runtimeId: this.params.execContext.sandboxName, + runtimeLabel: this.params.execContext.sandboxName, + workdir: this.params.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.execContext.config.from, + configLabelKind: "Source", + remoteWorkspaceDir: this.params.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await self.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await self.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await self.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: self.asHandle(), + }), + runRemoteShellScript: async (command) => await self.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await self.syncLocalPathToRemote(localPath, remotePath), + }; + } + + async prepareExec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise<{ argv: string[]; token: PendingExec }> { + await this.ensureSandboxExists(); + await this.syncWorkspaceToRemote(); + const sshSession = await createOpenShellSshSession({ + context: this.params.execContext, + }); + const remoteCommand = buildExecRemoteCommand({ + command: params.command, + workdir: params.workdir ?? this.params.remoteWorkspaceDir, + env: params.env, + }); + return { + argv: [ + "ssh", + "-F", + sshSession.configPath, + ...(params.usePty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + sshSession.host, + remoteCommand, + ], + token: { sshSession }, + }; + } + + async finalizeExec(token?: PendingExec): Promise { + try { + await this.syncWorkspaceFromRemote(); + } finally { + if (token?.sshSession) { + await disposeOpenShellSshSession(token.sshSession); + } + } + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureSandboxExists(); + const session = await createOpenShellSshSession({ + context: this.params.execContext, + }); + try { + return await runOpenShellSshCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-openshell-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeOpenShellSshSession(session); + } + } + + async syncLocalPathToRemote(localPath: string, remotePath: string): Promise { + await this.ensureSandboxExists(); + const stats = await fs.lstat(localPath).catch(() => null); + if (!stats) { + await this.runRemoteShellScript({ + script: 'rm -rf -- "$1"', + args: [remotePath], + allowFailure: true, + }); + return; + } + if (stats.isDirectory()) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [remotePath], + }); + return; + } + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$1")"', + args: [remotePath], + }); + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + path.posix.dirname(remotePath), + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } + + private async ensureSandboxExists(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureSandboxExistsInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureSandboxExistsInner(): Promise { + const getResult = await runOpenShellCli({ + context: this.params.execContext, + args: ["sandbox", "get", this.params.execContext.sandboxName], + cwd: this.params.createParams.workspaceDir, + }); + if (getResult.code === 0) { + return; + } + const createArgs = [ + "sandbox", + "create", + "--name", + this.params.execContext.sandboxName, + "--from", + this.params.execContext.config.from, + ...(this.params.execContext.config.policy + ? ["--policy", this.params.execContext.config.policy] + : []), + ...(this.params.execContext.config.gpu ? ["--gpu"] : []), + ...(this.params.execContext.config.autoProviders + ? ["--auto-providers"] + : ["--no-auto-providers"]), + ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]), + "--", + "true", + ]; + const createResult = await runOpenShellCli({ + context: this.params.execContext, + args: createArgs, + cwd: this.params.createParams.workspaceDir, + timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000), + }); + if (createResult.code !== 0) { + throw new Error(createResult.stderr.trim() || "openshell sandbox create failed"); + } + } + + private async syncWorkspaceToRemote(): Promise { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.workspaceDir, + this.params.remoteWorkspaceDir, + ); + + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteAgentWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.agentWorkspaceDir, + this.params.remoteAgentWorkspaceDir, + ); + } + } + + private async syncWorkspaceFromRemote(): Promise { + const tmpDir = await fs.mkdtemp( + path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"), + ); + try { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "download", + this.params.execContext.sandboxName, + this.params.remoteWorkspaceDir, + tmpDir, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox download failed"); + } + await replaceDirectoryContents({ + sourceDir: tmpDir, + targetDir: this.params.createParams.workspaceDir, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } + + private async uploadPathToRemote(localPath: string, remotePath: string): Promise { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + remotePath, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } +} + +function resolveOpenShellPluginConfigFromConfig( + config: OpenClawConfig, + fallback: ResolvedOpenShellPluginConfig, +): ResolvedOpenShellPluginConfig { + const pluginConfig = config.plugins?.entries?.openshell?.config; + if (!pluginConfig) { + return fallback; + } + return resolveOpenShellPluginConfig(pluginConfig); +} + +function buildOpenShellSandboxName(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} + +function resolveOpenShellTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts new file mode 100644 index 00000000000..d039a571ebc --- /dev/null +++ b/extensions/openshell/src/cli.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildExecRemoteCommand, buildOpenShellBaseArgv, shellEscape } from "./cli.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell cli helpers", () => { + it("builds base argv with gateway overrides", () => { + const config = resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + gatewayEndpoint: "https://lab.example", + }); + expect(buildOpenShellBaseArgv(config)).toEqual([ + "/usr/local/bin/openshell", + "--gateway", + "lab", + "--gateway-endpoint", + "https://lab.example", + ]); + }); + + it("shell escapes single quotes", () => { + expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); + }); + + it("wraps exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts new file mode 100644 index 00000000000..8f9808b5164 --- /dev/null +++ b/extensions/openshell/src/cli.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + resolvePreferredOpenClawTmpDir, + runPluginCommandWithTimeout, +} from "openclaw/plugin-sdk/core"; +import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; +import type { ResolvedOpenShellPluginConfig } from "./config.js"; + +export type OpenShellExecContext = { + config: ResolvedOpenShellPluginConfig; + sandboxName: string; + timeoutMs?: number; +}; + +export type OpenShellSshSession = { + configPath: string; + host: string; +}; + +export type OpenShellRunSshCommandParams = { + session: OpenShellSshSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { + const argv = [config.command]; + if (config.gateway) { + argv.push("--gateway", config.gateway); + } + if (config.gatewayEndpoint) { + argv.push("--gateway-endpoint", config.gatewayEndpoint); + } + return argv; +} + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export async function runOpenShellCli(params: { + context: OpenShellExecContext; + args: string[]; + cwd?: string; + timeoutMs?: number; +}): Promise<{ code: number; stdout: string; stderr: string }> { + return await runPluginCommandWithTimeout({ + argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args], + cwd: params.cwd, + timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs, + env: process.env, + }); +} + +export async function createOpenShellSshSession(params: { + context: OpenShellExecContext; +}): Promise { + const result = await runOpenShellCli({ + context: params.context, + args: ["sandbox", "ssh-config", params.context.sandboxName], + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); + } + const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); + const host = hostMatch?.[1]?.trim(); + if (!host) { + throw new Error("Failed to parse openshell ssh-config output."); + } + const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); + await fs.mkdir(tmpRoot, { recursive: true }); + const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, result.stdout, "utf8"); + return { configPath, host }; +} + +export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runOpenShellSshCommand( + params: OpenShellRunSshCommandParams, +): Promise { + const argv = [ + "ssh", + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; + + const result = await new Promise((resolve, reject) => { + const child = spawn(argv[0]!, argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + const error = Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ); + reject(error); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); + + return result; +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts new file mode 100644 index 00000000000..66734ca43e0 --- /dev/null +++ b/extensions/openshell/src/config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell plugin config", () => { + it("applies defaults", () => { + expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + command: "openshell", + gateway: undefined, + gatewayEndpoint: undefined, + from: "openclaw", + policy: undefined, + providers: [], + gpu: false, + autoProviders: true, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + timeoutMs: 120_000, + }); + }); + + it("rejects relative remote paths", () => { + expect(() => + resolveOpenShellPluginConfig({ + remoteWorkspaceDir: "sandbox", + }), + ).toThrow("OpenShell remote path must be absolute"); + }); +}); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts new file mode 100644 index 00000000000..53e5f06584b --- /dev/null +++ b/extensions/openshell/src/config.ts @@ -0,0 +1,225 @@ +import path from "node:path"; +import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; + +export type OpenShellPluginConfig = { + command?: string; + gateway?: string; + gatewayEndpoint?: string; + from?: string; + policy?: string; + providers?: string[]; + gpu?: boolean; + autoProviders?: boolean; + remoteWorkspaceDir?: string; + remoteAgentWorkspaceDir?: string; + timeoutSeconds?: number; +}; + +export type ResolvedOpenShellPluginConfig = { + command: string; + gateway?: string; + gatewayEndpoint?: string; + from: string; + policy?: string; + providers: string[]; + gpu: boolean; + autoProviders: boolean; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + timeoutMs: number; +}; + +const DEFAULT_COMMAND = "openshell"; +const DEFAULT_SOURCE = "openclaw"; +const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; +const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; +const DEFAULT_TIMEOUT_MS = 120_000; + +type ParseSuccess = { success: true; data?: OpenShellPluginConfig }; +type ParseFailure = { + success: false; + error: { + issues: Array<{ path: Array; message: string }>; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeProviders(value: unknown): string[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return null; + } + const seen = new Set(); + const providers: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !entry.trim()) { + return null; + } + const normalized = entry.trim(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + providers.push(normalized); + } + return providers; +} + +function normalizeRemotePath(value: string | undefined, fallback: string): string { + const candidate = value ?? fallback; + const normalized = path.posix.normalize(candidate.trim() || fallback); + if (!normalized.startsWith("/")) { + throw new Error(`OpenShell remote path must be absolute: ${candidate}`); + } + return normalized; +} + +export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema { + const safeParse = (value: unknown): ParseSuccess | ParseFailure => { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!isRecord(value)) { + return { + success: false, + error: { issues: [{ path: [], message: "expected config object" }] }, + }; + } + const allowedKeys = new Set([ + "command", + "gateway", + "gatewayEndpoint", + "from", + "policy", + "providers", + "gpu", + "autoProviders", + "remoteWorkspaceDir", + "remoteAgentWorkspaceDir", + "timeoutSeconds", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + return { + success: false, + error: { issues: [{ path: [key], message: `unknown config key: ${key}` }] }, + }; + } + } + + const providers = normalizeProviders(value.providers); + if (providers === null) { + return { + success: false, + error: { + issues: [{ path: ["providers"], message: "providers must be an array of strings" }], + }, + }; + } + + const timeoutSeconds = value.timeoutSeconds; + if ( + timeoutSeconds !== undefined && + (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1) + ) { + return { + success: false, + error: { + issues: [{ path: ["timeoutSeconds"], message: "timeoutSeconds must be a number >= 1" }], + }, + }; + } + + for (const key of ["gpu", "autoProviders"] as const) { + const candidate = value[key]; + if (candidate !== undefined && typeof candidate !== "boolean") { + return { + success: false, + error: { issues: [{ path: [key], message: `${key} must be a boolean` }] }, + }; + } + } + + return { + success: true, + data: { + command: trimString(value.command), + gateway: trimString(value.gateway), + gatewayEndpoint: trimString(value.gatewayEndpoint), + from: trimString(value.from), + policy: trimString(value.policy), + providers, + gpu: value.gpu as boolean | undefined, + autoProviders: value.autoProviders as boolean | undefined, + remoteWorkspaceDir: trimString(value.remoteWorkspaceDir), + remoteAgentWorkspaceDir: trimString(value.remoteAgentWorkspaceDir), + timeoutSeconds: timeoutSeconds as number | undefined, + }, + }; + }; + + return { + safeParse, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string" }, + gateway: { type: "string" }, + gatewayEndpoint: { type: "string" }, + from: { type: "string" }, + policy: { type: "string" }, + providers: { type: "array", items: { type: "string" } }, + gpu: { type: "boolean" }, + autoProviders: { type: "boolean" }, + remoteWorkspaceDir: { type: "string" }, + remoteAgentWorkspaceDir: { type: "string" }, + timeoutSeconds: { type: "number", minimum: 1 }, + }, + }, + }; +} + +export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig { + const parsed = createOpenShellPluginConfigSchema().safeParse?.(value); + if (!parsed || !parsed.success) { + const issues = parsed && !parsed.success ? parsed.error?.issues : undefined; + const message = + issues?.map((issue: { message: string }) => issue.message).join(", ") || "invalid config"; + throw new Error(`Invalid openshell plugin config: ${message}`); + } + const raw = parsed.data ?? {}; + const cfg = (raw ?? {}) as OpenShellPluginConfig; + return { + command: cfg.command ?? DEFAULT_COMMAND, + gateway: cfg.gateway, + gatewayEndpoint: cfg.gatewayEndpoint, + from: cfg.from ?? DEFAULT_SOURCE, + policy: cfg.policy, + providers: cfg.providers ?? [], + gpu: cfg.gpu ?? false, + autoProviders: cfg.autoProviders ?? true, + remoteWorkspaceDir: normalizeRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR), + remoteAgentWorkspaceDir: normalizeRemotePath( + cfg.remoteAgentWorkspaceDir, + DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + ), + timeoutMs: + typeof cfg.timeoutSeconds === "number" + ? Math.floor(cfg.timeoutSeconds * 1000) + : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/extensions/openshell/src/fs-bridge.test.ts b/extensions/openshell/src/fs-bridge.test.ts new file mode 100644 index 00000000000..67a3edc5bcc --- /dev/null +++ b/extensions/openshell/src/fs-bridge.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-fs-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function createBackendMock(): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn().mockResolvedValue({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +describe("openshell fs bridge", () => { + it("writes locally and syncs the file to the remote workspace", async () => { + const workspaceDir = await makeTempDir(); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); + expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( + path.join(workspaceDir, "nested", "file.txt"), + "/sandbox/nested/file.txt", + ); + }); + + it("maps agent mount paths when the sandbox workspace is read-only", async () => { + const workspaceDir = await makeTempDir(); + const agentWorkspaceDir = await makeTempDir(); + await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + workspaceAccess: "ro", + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); + expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); + expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); + }); +}); diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts new file mode 100644 index 00000000000..b9ab9b01549 --- /dev/null +++ b/extensions/openshell/src/fs-bridge.ts @@ -0,0 +1,336 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { movePathWithCopyFallback } from "./mirror.js"; + +type ResolvedMountPath = SandboxResolvedPath & { + mountHostRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellFsBridge(params.sandbox, params.backend); +} + +class OpenShellFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + hostPath: target.hostPath, + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return await fsPromises.readFile(target.hostPath); + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + const parentDir = path.dirname(target.hostPath); + if (params.mkdir !== false) { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + const tempPath = path.join( + parentDir, + `.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`, + ); + await fsPromises.writeFile(tempPath, buffer); + await fsPromises.rename(tempPath, target.hostPath); + await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(target.hostPath, { recursive: true }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [target.containerPath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: params.force !== false, + allowFinalSymlinkForUnlink: true, + }); + await fsPromises.rm(target.hostPath, { + recursive: params.recursive ?? false, + force: params.force !== false, + }); + await this.backend.runRemoteShellScript({ + script: params.recursive + ? 'rm -rf -- "$1"' + : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi', + args: [target.containerPath], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + await assertLocalPathSafety({ + target: from, + root: from.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: true, + }); + await assertLocalPathSafety({ + target: to, + root: to.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true }); + await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', + args: [from.containerPath, to.containerPath], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const stats = await fsPromises.lstat(target.hostPath).catch(() => null); + if (!stats) { + return null; + } + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return { + type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", + size: stats.size, + mtimeMs: stats.mtimeMs, + }; + } + + private ensureWritable(target: ResolvedMountPath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot; + const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace( + /\\/g, + "/", + ); + const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/"); + const input = params.filePath.trim(); + + if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) { + const relative = path.posix.relative(workspaceContainerRoot, input) || ""; + const hostPath = relative + ? path.resolve(workspaceRoot, ...relative.split("/")) + : workspaceRoot; + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if ( + hasAgentMount && + (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot) + ) { + const relative = path.posix.relative(agentContainerRoot, input) || ""; + const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot; + return { + hostPath, + relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input); + + if (isPathInside(workspaceRoot, hostPath)) { + const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if (hasAgentMount && isPathInside(agentRoot, hostPath)) { + const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`); + } +} + +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function assertLocalPathSafety(params: { + target: ResolvedMountPath; + root: string; + allowMissingLeaf: boolean; + allowFinalSymlinkForUnlink: boolean; +}): Promise { + const canonicalRoot = await fsPromises + .realpath(params.root) + .catch(() => path.resolve(params.root)); + const candidate = await resolveCanonicalCandidate(params.target.hostPath); + if (!isPathInside(canonicalRoot, candidate)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, + ); + } + + const relative = path.relative(params.root, params.target.hostPath); + const segments = relative + .split(path.sep) + .filter(Boolean) + .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length)); + let cursor = params.root; + for (let index = 0; index < segments.length; index += 1) { + cursor = path.join(cursor, segments[index]!); + const stats = await fsPromises.lstat(cursor).catch(() => null); + if (!stats) { + if (index === segments.length - 1 && params.allowMissingLeaf) { + return; + } + continue; + } + const isFinal = index === segments.length - 1; + if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) { + throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`); + } + } +} + +async function resolveCanonicalCandidate(targetPath: string): Promise { + const missing: string[] = []; + let cursor = path.resolve(targetPath); + while (true) { + const exists = await fsPromises + .lstat(cursor) + .then(() => true) + .catch(() => false); + if (exists) { + const canonical = await fsPromises.realpath(cursor).catch(() => cursor); + return path.resolve(canonical, ...missing); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + return path.resolve(cursor, ...missing); + } + missing.unshift(path.basename(cursor)); + cursor = parent; + } +} diff --git a/extensions/openshell/src/mirror.ts b/extensions/openshell/src/mirror.ts new file mode 100644 index 00000000000..ee5024850d6 --- /dev/null +++ b/extensions/openshell/src/mirror.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function replaceDirectoryContents(params: { + sourceDir: string; + targetDir: string; +}): Promise { + await fs.mkdir(params.targetDir, { recursive: true }); + const existing = await fs.readdir(params.targetDir); + await Promise.all( + existing.map((entry) => + fs.rm(path.join(params.targetDir, entry), { + recursive: true, + force: true, + }), + ), + ); + const sourceEntries = await fs.readdir(params.sourceDir); + for (const entry of sourceEntries) { + await fs.cp(path.join(params.sourceDir, entry), path.join(params.targetDir, entry), { + recursive: true, + force: true, + dereference: false, + }); + } +} + +export async function movePathWithCopyFallback(params: { + from: string; + to: string; +}): Promise { + try { + await fs.rename(params.from, params.to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== "EXDEV") { + throw error; + } + } + await fs.cp(params.from, params.to, { + recursive: true, + force: true, + dereference: false, + }); + await fs.rm(params.from, { recursive: true, force: true }); +} diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 5c3301414b9..72367deb33d 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -384,6 +384,7 @@ export async function runExecProcess(opts: { typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? Math.floor(opts.timeoutSec * 1000) : undefined; + let sandboxFinalizeToken: unknown; const spawnSpec: | { @@ -398,11 +399,18 @@ export async function runExecProcess(opts: { childFallbackArgv: string[]; env: NodeJS.ProcessEnv; stdinMode: "pipe-open"; - } = (() => { + } = await (async () => { if (opts.sandbox) { + const backendExecSpec = await opts.sandbox.buildExecSpec?.({ + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: shellRuntimeEnv, + usePty: opts.usePty, + }); + sandboxFinalizeToken = backendExecSpec?.finalizeToken; return { mode: "child" as const, - argv: [ + argv: backendExecSpec?.argv ?? [ "docker", ...buildDockerExecArgs({ containerName: opts.sandbox.containerName, @@ -412,8 +420,10 @@ export async function runExecProcess(opts: { tty: opts.usePty, }), ], - env: process.env, - stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + env: backendExecSpec?.env ?? process.env, + stdinMode: + backendExecSpec?.stdinMode ?? + (opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const)), }; } const { shell, args: shellArgs } = getShellConfig(); @@ -519,7 +529,7 @@ export async function runExecProcess(opts: { const promise = managedRun .wait() - .then((exit): ExecProcessOutcome => { + .then(async (exit): Promise => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; const exitCode = exit.exitCode ?? 0; @@ -536,6 +546,14 @@ export async function runExecProcess(opts: { session.stdin.destroyed = true; } const aggregated = session.aggregated.trim(); + if (opts.sandbox?.finalizeExec) { + await opts.sandbox.finalizeExec({ + status, + exitCode: exit.exitCode ?? null, + timedOut: exit.timedOut, + token: sandboxFinalizeToken, + }); + } if (status === "completed") { const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 3cfb92655e2..25f1fb5bd8d 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; +import type { SandboxBackendExecSpec } from "./sandbox/backend.js"; const CHUNK_LIMIT = 8 * 1024; @@ -12,6 +13,18 @@ export type BashSandboxConfig = { workspaceDir: string; containerWorkdir: string; env?: Record; + buildExecSpec?: (params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }) => Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; }; export function buildSandboxEnv(params: { diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index 8b225ff89cb..52289130690 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -5,10 +5,13 @@ import type { SandboxContext } from "./sandbox.js"; function createSandboxContext(overrides?: Partial): SandboxContext { const base = { enabled: true, + backendId: "docker", sessionKey: "session:test", workspaceDir: "/tmp/openclaw-sandbox", agentWorkspaceDir: "/tmp/openclaw-workspace", workspaceAccess: "none", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index e24186e0b30..353b0333759 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -574,10 +574,13 @@ describe("Agent-specific tool filtering", () => { agentDir: "/tmp/agent-restricted", sandbox: { enabled: true, + backendId: "docker", sessionKey: "agent:restricted:main", workspaceDir: "/tmp/sandbox", agentWorkspaceDir: "/tmp/test-restricted", workspaceAccess: "none", + runtimeId: "test-container", + runtimeLabel: "test-container", containerName: "test-container", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..9c7aafbd56e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -438,7 +438,9 @@ export function createOpenClawCodingTools(options?: { containerName: sandbox.containerName, workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, - env: sandbox.docker.env, + env: sandbox.backend?.env ?? sandbox.docker.env, + buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend), + finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend), } : undefined, }); diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 0635703b8bb..d120ac84820 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSandboxBrowserConfig, + resolveSandboxConfigForAgent, resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, @@ -128,4 +129,8 @@ describe("sandbox config merges", () => { }); expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + + it("defaults sandbox backend to docker", () => { + expect(resolveSandboxConfigForAgent().backend).toBe("docker"); + }); }); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 2ecec621a70..0fa62a364e2 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerSandboxBackend } from "./sandbox/backend.js"; import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; describe("resolveSandboxContext", () => { @@ -84,4 +85,45 @@ describe("resolveSandboxContext", () => { }), ).toBeNull(); }, 15_000); + + it("resolves a registered non-docker backend", async () => { + const restore = registerSandboxBackend("test-backend", async () => ({ + id: "test-backend", + runtimeId: "test-runtime", + runtimeLabel: "Test Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["test-backend", "exec"], + env: process.env, + stdinMode: "pipe-closed", + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { mode: "all", backend: "test-backend", scope: "session" }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:task", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("test-backend"); + expect(result?.runtimeId).toBe("test-runtime"); + expect(result?.containerName).toBe("test-runtime"); + expect(result?.backend?.id).toBe("test-backend"); + } finally { + restore(); + } + }, 15_000); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d0f..b52cb5ab050 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -11,6 +11,12 @@ export { DEFAULT_SANDBOX_IMAGE, } from "./sandbox/constants.js"; export { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "./sandbox/backend.js"; export { buildSandboxCreateArgs } from "./sandbox/docker.js"; export { @@ -27,6 +33,20 @@ export { } from "./sandbox/runtime-status.js"; export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; +export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; + +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, +} from "./sandbox/backend.js"; export type { SandboxBrowserConfig, diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts new file mode 100644 index 00000000000..6878e768945 --- /dev/null +++ b/src/agents/sandbox/backend.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, +} from "./backend.js"; + +describe("sandbox backend registry", () => { + it("registers and restores backend factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const restore = registerSandboxBackend("test-backend", factory); + expect(getSandboxBackendFactory("test-backend")).toBe(factory); + restore(); + expect(getSandboxBackendFactory("test-backend")).toBeNull(); + }); + + it("registers backend managers alongside factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const manager = { + describeRuntime: async () => ({ + running: true, + configLabelMatch: true, + }), + removeRuntime: async () => {}, + }; + const restore = registerSandboxBackend("test-managed", { + factory, + manager, + }); + expect(getSandboxBackendFactory("test-managed")).toBe(factory); + expect(getSandboxBackendManager("test-managed")).toBe(manager); + restore(); + expect(getSandboxBackendManager("test-managed")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts new file mode 100644 index 00000000000..c186b0fe4cc --- /dev/null +++ b/src/agents/sandbox/backend.ts @@ -0,0 +1,148 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxFsBridge } from "./fs-bridge.js"; +import type { SandboxRegistryEntry } from "./registry.js"; +import type { SandboxConfig, SandboxContext } from "./types.js"; + +export type SandboxBackendId = string; + +export type SandboxBackendExecSpec = { + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + finalizeToken?: unknown; +}; + +export type SandboxBackendCommandParams = { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; +}; + +export type SandboxBackendCommandResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +export type SandboxBackendHandle = { + id: SandboxBackendId; + runtimeId: string; + runtimeLabel: string; + workdir: string; + env?: Record; + configLabel?: string; + configLabelKind?: string; + capabilities?: { + browser?: boolean; + }; + buildExecSpec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; + runShellCommand(params: SandboxBackendCommandParams): Promise; + createFsBridge?: (params: { sandbox: SandboxContext }) => SandboxFsBridge; +}; + +export type SandboxBackendRuntimeInfo = { + running: boolean; + actualConfigLabel?: string; + configLabelMatch: boolean; +}; + +export type SandboxBackendManager = { + describeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; + removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; +}; + +export type CreateSandboxBackendParams = { + sessionKey: string; + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; +}; + +export type SandboxBackendFactory = ( + params: CreateSandboxBackendParams, +) => Promise; + +export type SandboxBackendRegistration = + | SandboxBackendFactory + | { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; + }; + +type RegisteredSandboxBackend = { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; +}; + +const SANDBOX_BACKEND_FACTORIES = new Map(); + +function normalizeSandboxBackendId(id: string): SandboxBackendId { + const normalized = id.trim().toLowerCase(); + if (!normalized) { + throw new Error("Sandbox backend id must not be empty."); + } + return normalized; +} + +export function registerSandboxBackend( + id: string, + registration: SandboxBackendRegistration, +): () => void { + const normalizedId = normalizeSandboxBackendId(id); + const resolved = typeof registration === "function" ? { factory: registration } : registration; + const previous = SANDBOX_BACKEND_FACTORIES.get(normalizedId); + SANDBOX_BACKEND_FACTORIES.set(normalizedId, resolved); + return () => { + if (previous) { + SANDBOX_BACKEND_FACTORIES.set(normalizedId, previous); + return; + } + SANDBOX_BACKEND_FACTORIES.delete(normalizedId); + }; +} + +export function getSandboxBackendFactory(id: string): SandboxBackendFactory | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.factory ?? null; +} + +export function getSandboxBackendManager(id: string): SandboxBackendManager | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.manager ?? null; +} + +export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { + const factory = getSandboxBackendFactory(id); + if (factory) { + return factory; + } + throw new Error( + [ + `Sandbox backend "${id}" is not registered.`, + "Load the plugin that provides it, or set agents.defaults.sandbox.backend=docker.", + ].join("\n"), + ); +} + +import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; + +registerSandboxBackend("docker", { + factory: createDockerSandboxBackend, + manager: dockerSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 077db23c53b..c62276c6b87 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -48,6 +48,7 @@ vi.mock("../../browser/bridge-server.js", () => ({ function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", + backend: "docker", scope: "session", workspaceAccess: "none", workspaceRoot: "/tmp/openclaw-sandboxes", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index b7595ae8c4b..dda3e048ea7 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -189,6 +189,7 @@ export function resolveSandboxConfigForAgent( return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", + backend: agentSandbox?.backend?.trim() || agent?.backend?.trim() || "docker", scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 8468dd2c556..031b7c45998 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -7,11 +7,12 @@ import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; +import { requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; -import { ensureSandboxContainer } from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { maybePruneSandboxes } from "./prune.js"; +import { updateRegistry } from "./registry.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; @@ -131,12 +132,24 @@ export async function resolveSandboxContext(params: { }); const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker }; - const containerName = await ensureSandboxContainer({ + const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend); + const backend = await backendFactory({ sessionKey: rawSessionKey, + scopeKey, workspaceDir, agentWorkspaceDir, cfg: resolvedCfg, }); + await updateRegistry({ + containerName: backend.runtimeId, + backendId: backend.id, + runtimeLabel: backend.runtimeLabel, + sessionKey: scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: backend.configLabel ?? resolvedCfg.docker.image, + configLabelKind: backend.configLabelKind ?? "Image", + }); const evaluateEnabled = params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; @@ -157,30 +170,44 @@ export async function resolveSandboxContext(params: { return browserAuth; })() : undefined; - const browser = await ensureSandboxBrowser({ - scopeKey, - workspaceDir, - agentWorkspaceDir, - cfg: resolvedCfg, - evaluateEnabled, - bridgeAuth, - }); + if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) { + throw new Error( + `Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`, + ); + } + const browser = + resolvedCfg.browser.enabled && backend.capabilities?.browser === true + ? await ensureSandboxBrowser({ + scopeKey, + workspaceDir, + agentWorkspaceDir, + cfg: resolvedCfg, + evaluateEnabled, + bridgeAuth, + }) + : null; const sandboxContext: SandboxContext = { enabled: true, + backendId: backend.id, sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, workspaceAccess: resolvedCfg.workspaceAccess, - containerName, - containerWorkdir: resolvedCfg.docker.workdir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, docker: resolvedCfg.docker, tools: resolvedCfg.tools, browserAllowHostControl: resolvedCfg.browser.allowHostControl, browser: browser ?? undefined, + backend, }; - sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext }); + sandboxContext.fsBridge = + backend.createFsBridge?.({ sandbox: sandboxContext }) ?? + createSandboxFsBridge({ sandbox: sandboxContext }); return sandboxContext; } diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts new file mode 100644 index 00000000000..9686dc4b612 --- /dev/null +++ b/src/agents/sandbox/docker-backend.ts @@ -0,0 +1,130 @@ +import { buildDockerExecArgs } from "../bash-tools.shared.js"; +import type { + CreateSandboxBackendParams, + SandboxBackendManager, + SandboxBackendCommandParams, + SandboxBackendHandle, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + dockerContainerState, + ensureSandboxContainer, + execDocker, + execDockerRaw, +} from "./docker.js"; + +export async function createDockerSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + const containerName = await ensureSandboxContainer({ + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + cfg: params.cfg, + }); + return createDockerSandboxBackendHandle({ + containerName, + workdir: params.cfg.docker.workdir, + env: params.cfg.docker.env, + image: params.cfg.docker.image, + }); +} + +export function createDockerSandboxBackendHandle(params: { + containerName: string; + workdir: string; + env?: Record; + image: string; +}): SandboxBackendHandle { + return { + id: "docker", + runtimeId: params.containerName, + runtimeLabel: params.containerName, + workdir: params.workdir, + env: params.env, + configLabel: params.image, + configLabelKind: "Image", + capabilities: { + browser: true, + }, + async buildExecSpec({ command, workdir, env, usePty }) { + return { + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: params.containerName, + command, + workdir: workdir ?? params.workdir, + env, + tty: usePty, + }), + ], + env: process.env, + stdinMode: usePty ? "pipe-open" : "pipe-closed", + }; + }, + runShellCommand(command) { + return runDockerSandboxShellCommand({ + containerName: params.containerName, + ...command, + }); + }, + }; +} + +export function runDockerSandboxShellCommand( + params: { + containerName: string; + } & SandboxBackendCommandParams, +) { + const dockerArgs = [ + "exec", + "-i", + params.containerName, + "sh", + "-c", + params.script, + "moltbot-sandbox-fs", + ]; + if (params.args?.length) { + dockerArgs.push(...params.args); + } + return execDockerRaw(dockerArgs, { + input: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); +} + +export const dockerSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const state = await dockerContainerState(entry.containerName); + let actualConfigLabel = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualConfigLabel = result.stdout.trim() || actualConfigLabel; + } + } catch { + // ignore inspect failures + } + } + const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + return { + running: state.running, + actualConfigLabel, + configLabelMatch: actualConfigLabel === configuredImage, + }; + }, + async removeRuntime({ entry }) { + try { + await execDocker(["rm", "-f", entry.containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + }, +}; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b2cd24c6630..54941ba04d1 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -91,6 +91,7 @@ function createSandboxConfig( ): SandboxConfig { return { mode: "all", + backend: "docker", scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aefceb08495..80a2921cb6b 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -557,10 +557,13 @@ export async function ensureSandboxContainer(params: { } await updateRegistry({ containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, + configLabelKind: "Image", configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, }); return containerName; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 7a9a22d4459..16c307e053c 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; -import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; +import { runDockerSandboxShellCommand } from "./docker-backend.js"; import { buildPinnedMkdirpPlan, buildPinnedRemovePlan, @@ -248,21 +249,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCommand( script: string, options: RunCommandOptions = {}, - ): Promise { - const dockerArgs = [ - "exec", - "-i", - this.sandbox.containerName, - "sh", - "-c", - script, - "moltbot-sandbox-fs", - ]; - if (options.args?.length) { - dockerArgs.push(...options.args); + ): Promise { + const backend = this.sandbox.backend; + if (backend) { + return await backend.runShellCommand({ + script, + args: options.args, + stdin: options.stdin, + allowFailure: options.allowFailure, + signal: options.signal, + }); } - return execDockerRaw(dockerArgs, { - input: options.stdin, + return await runDockerSandboxShellCommand({ + containerName: this.sandbox.containerName, + script, + args: options.args, + stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal, }); @@ -279,7 +281,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCheckedCommand( plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal }, - ): Promise { + ): Promise { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) { await this.pathGuard.assertPathChecks(plan.checks); @@ -295,7 +297,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runPlannedCommand( plan: SandboxFsCommandPlan, signal?: AbortSignal, - ): Promise { + ): Promise { return await this.runCheckedCommand({ ...plan, signal }); } diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index f6988146e90..0b5ba578d7d 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,8 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { loadConfig } from "../../config/config.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { resolveSandboxConfigForAgent } from "./config.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -23,80 +23,92 @@ export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { imageMatch: boolean; }; -async function listSandboxRegistryItems< - TEntry extends { containerName: string; image: string; sessionKey: string }, ->(params: { - read: () => Promise<{ entries: TEntry[] }>; - resolveConfiguredImage: (agentId?: string) => string; -}): Promise> { - const registry = await params.read(); - const results: Array = []; +export async function listSandboxContainers(): Promise { + const config = loadConfig(); + const registry = await readRegistry(); + const results: SandboxContainerInfo[] = []; for (const entry of registry.entries) { - const state = await dockerContainerState(entry.containerName); - // Get actual image from container. - let actualImage = entry.image; - if (state.exists) { - try { - const result = await execDocker( - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualImage = result.stdout.trim(); - } - } catch { - // ignore - } + const backendId = entry.backendId ?? "docker"; + const manager = getSandboxBackendManager(backendId); + if (!manager) { + results.push({ + ...entry, + running: false, + imageMatch: true, + }); + continue; } const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = params.resolveConfiguredImage(agentId); + const runtime = await manager.describeRuntime({ + entry, + config, + agentId, + }); results.push({ ...entry, - image: actualImage, - running: state.running, - imageMatch: actualImage === configuredImage, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, }); } return results; } -export async function listSandboxContainers(): Promise { - const config = loadConfig(); - return listSandboxRegistryItems({ - read: readRegistry, - resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image, - }); -} - export async function listSandboxBrowsers(): Promise { const config = loadConfig(); - return listSandboxRegistryItems({ - read: readBrowserRegistry, - resolveConfiguredImage: (agentId) => - resolveSandboxConfigForAgent(config, agentId).browser.image, - }); + const registry = await readBrowserRegistry(); + const results: SandboxBrowserInfo[] = []; + + for (const entry of registry.entries) { + const agentId = resolveSandboxAgentId(entry.sessionKey); + const runtime = await dockerSandboxBackendManager.describeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + config, + agentId, + }); + results.push({ + ...entry, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, + }); + } + + return results; } export async function removeSandboxContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readBrowserRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); } await removeBrowserRegistryEntry(containerName); - // Stop browser bridge if active for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) { if (bridge.containerName === containerName) { await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 45e7fda6308..6ccfd8ac238 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,7 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { defaultRuntime } from "../../runtime.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -16,7 +17,7 @@ let lastPruneAtMs = 0; type PruneableRegistryEntry = Pick< SandboxRegistryEntry, - "containerName" | "createdAtMs" | "lastUsedAtMs" + "containerName" | "backendId" | "createdAtMs" | "lastUsedAtMs" >; function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: PruneableRegistryEntry) { @@ -33,10 +34,11 @@ function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: Pruneab ); } -async function pruneSandboxRegistryEntries(params: { +async function pruneSandboxRegistryEntries(params: { cfg: SandboxConfig; read: () => Promise<{ entries: TEntry[] }>; remove: (containerName: string) => Promise; + removeRuntime: (entry: TEntry) => Promise; onRemoved?: (entry: TEntry) => Promise; }) { const now = Date.now(); @@ -49,9 +51,7 @@ async function pruneSandboxRegistryEntries { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); + }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { - await pruneSandboxRegistryEntries({ + await pruneSandboxRegistryEntries< + SandboxBrowserRegistryEntry & { + backendId?: string; + runtimeLabel?: string; + configLabelKind?: string; + } + >({ cfg, read: readBrowserRegistry, remove: removeBrowserRegistryEntry, + removeRuntime: async (entry) => { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); + }, onRemoved: async (entry) => { const bridge = BROWSER_BRIDGES.get(entry.sessionKey); if (bridge?.containerName === entry.containerName) { @@ -103,10 +123,3 @@ export async function maybePruneSandboxes(cfg: SandboxConfig) { defaultRuntime.error?.(`Sandbox prune failed: ${message ?? "unknown error"}`); } } - -export async function ensureDockerContainerIsRunning(containerName: string) { - const state = await dockerContainerState(containerName); - if (state.exists && !state.running) { - await execDocker(["start", containerName]); - } -} diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 2de75190bf8..059e6f77c88 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -172,6 +172,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { } describe("registry race safety", () => { + it("normalizes legacy registry entries on read", async () => { + await seedContainerRegistry([ + { + containerName: "legacy-container", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:test", + }, + ]); + + const registry = await readRegistry(); + expect(registry.entries).toEqual([ + expect.objectContaining({ + containerName: "legacy-container", + backendId: "docker", + runtimeLabel: "legacy-container", + configLabelKind: "Image", + }), + ]); + }); + it("keeps both container updates under concurrent writes", async () => { await Promise.all([ updateRegistry(containerEntry({ containerName: "container-a" })), diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 54bb361934b..f8efebbf32b 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -5,10 +5,13 @@ import { SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_REGISTRY_PATH } from "./constant export type SandboxRegistryEntry = { containerName: string; + backendId?: string; + runtimeLabel?: string; sessionKey: string; createdAtMs: number; lastUsedAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -42,8 +45,11 @@ type RegistryFile = { }; type UpsertEntry = RegistryEntry & { + backendId?: string; + runtimeLabel?: string; createdAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -55,6 +61,15 @@ function isRegistryEntry(value: unknown): value is RegistryEntry { return isRecord(value) && typeof value.containerName === "string"; } +function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry { + return { + ...entry, + backendId: entry.backendId?.trim() || "docker", + runtimeLabel: entry.runtimeLabel?.trim() || entry.containerName, + configLabelKind: entry.configLabelKind?.trim() || "Image", + }; +} + function isRegistryFile(value: unknown): value is RegistryFile { if (!isRecord(value)) { return false; @@ -110,7 +125,13 @@ async function writeRegistryFile( } export async function readRegistry(): Promise { - return await readRegistryFromFile(SANDBOX_REGISTRY_PATH, "fallback"); + const registry = await readRegistryFromFile( + SANDBOX_REGISTRY_PATH, + "fallback", + ); + return { + entries: registry.entries.map((entry) => normalizeSandboxRegistryEntry(entry)), + }; } function upsertEntry(entries: T[], entry: T): T[] { @@ -118,8 +139,11 @@ function upsertEntry(entries: T[], entry: T): T[] { const next = entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, + backendId: entry.backendId ?? existing?.backendId, + runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, + configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, configHash: entry.configHash ?? existing?.configHash, }); return next; diff --git a/src/agents/sandbox/test-fixtures.ts b/src/agents/sandbox/test-fixtures.ts index db3835dcba5..b20b5b452f7 100644 --- a/src/agents/sandbox/test-fixtures.ts +++ b/src/agents/sandbox/test-fixtures.ts @@ -28,10 +28,13 @@ export function createSandboxTestContext(params?: { return { enabled: true, + backendId: "docker", sessionKey: "sandbox:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", workspaceAccess: "rw", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", tools: { allow: ["*"], deny: [] }, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 4ccfd691cfb..8244583ea0c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SandboxBackendHandle, SandboxBackendId } from "./backend.js"; import type { SandboxFsBridge } from "./fs-bridge.js"; import type { SandboxDockerConfig } from "./types.docker.js"; @@ -54,6 +55,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { mode: "off" | "non-main" | "all"; + backend: SandboxBackendId; scope: SandboxScope; workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; @@ -71,10 +73,13 @@ export type SandboxBrowserContext = { export type SandboxContext = { enabled: boolean; + backendId: SandboxBackendId; sessionKey: string; workspaceDir: string; agentWorkspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; + runtimeId: string; + runtimeLabel: string; containerName: string; containerWorkdir: string; docker: SandboxDockerConfig; @@ -82,6 +87,7 @@ export type SandboxContext = { browserAllowHostControl: boolean; browser?: SandboxBrowserContext; fsBridge?: SandboxFsBridge; + backend?: SandboxBackendHandle; }; export type SandboxWorkspaceInfo = { diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.ts b/src/agents/test-helpers/pi-tools-sandbox-context.ts index 286c5eed685..abf712c2c0b 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.ts +++ b/src/agents/test-helpers/pi-tools-sandbox-context.ts @@ -18,10 +18,13 @@ export function createPiToolsSandboxContext(params: PiToolsSandboxContextParams) const workspaceDir = params.workspaceDir; return { enabled: true, + backendId: "docker", sessionKey: params.sessionKey ?? "sandbox:test", workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir ?? workspaceDir, workspaceAccess: params.workspaceAccess ?? "rw", + runtimeId: params.containerName ?? "openclaw-sbx-test", + runtimeLabel: params.containerName ?? "openclaw-sbx-test", containerName: params.containerName ?? "openclaw-sbx-test", containerWorkdir: params.containerWorkdir ?? "/workspace", fsBridge: params.fsBridge, diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 90790e90737..2138c422fe2 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -94,6 +94,11 @@ function resolveSandboxDockerImage(cfg: OpenClawConfig): string { return image ? image : DEFAULT_SANDBOX_IMAGE; } +function resolveSandboxBackend(cfg: OpenClawConfig): string { + const backend = cfg.agents?.defaults?.sandbox?.backend?.trim(); + return backend || "docker"; +} + function resolveSandboxBrowserImage(cfg: OpenClawConfig): string { const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; @@ -185,6 +190,16 @@ export async function maybeRepairSandboxImages( if (!sandbox || mode === "off") { return cfg; } + const backend = resolveSandboxBackend(cfg); + if (backend !== "docker") { + if (sandbox.browser?.enabled) { + note( + `Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`, + "Sandbox", + ); + } + return cfg; + } const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index 181af6bcc1f..8eaf245c5bf 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -30,12 +30,15 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R displayItems( containers, { - emptyMessage: "No sandbox containers found.", - title: "📦 Sandbox Containers:", + emptyMessage: "No sandbox runtimes found.", + title: "📦 Sandbox Runtimes:", renderItem: (container, rt) => { - rt.log(` ${container.containerName}`); + rt.log(` ${container.runtimeLabel ?? container.containerName}`); rt.log(` Status: ${formatStatus(container.running)}`); - rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`); + rt.log( + ` ${container.configLabelKind ?? "Image"}: ${container.image} ${formatImageMatch(container.imageMatch)}`, + ); + rt.log(` Backend: ${container.backendId ?? "docker"}`); rt.log( ` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`, ); @@ -92,9 +95,9 @@ export function displaySummary( runtime.log(`Total: ${totalCount} (${runningCount} running)`); if (mismatchCount > 0) { - runtime.log(`\n⚠️ ${mismatchCount} container(s) with image mismatch detected.`); + runtime.log(`\n⚠️ ${mismatchCount} runtime(s) with config mismatch detected.`); runtime.log( - ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all containers.`, + ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all runtimes.`, ); } } @@ -104,12 +107,14 @@ export function displayRecreatePreview( browsers: SandboxBrowserInfo[], runtime: RuntimeEnv, ): void { - runtime.log("\nContainers to be recreated:\n"); + runtime.log("\nSandbox runtimes to be recreated:\n"); if (containers.length > 0) { - runtime.log("📦 Sandbox Containers:"); + runtime.log("📦 Sandbox Runtimes:"); for (const container of containers) { - runtime.log(` - ${container.containerName} (${formatSimpleStatus(container.running)})`); + runtime.log( + ` - ${container.runtimeLabel ?? container.containerName} [${container.backendId ?? "docker"}] (${formatSimpleStatus(container.running)})`, + ); } } @@ -121,7 +126,7 @@ export function displayRecreatePreview( } const total = containers.length + browsers.length; - runtime.log(`\nTotal: ${total} container(s)`); + runtime.log(`\nTotal: ${total} runtime(s)`); } export function displayRecreateResult( @@ -131,6 +136,6 @@ export function displayRecreateResult( runtime.log(`\nDone: ${result.successCount} removed, ${result.failCount} failed`); if (result.successCount > 0) { - runtime.log("\nContainers will be automatically recreated when the agent is next used."); + runtime.log("\nRuntimes will be automatically recreated when the agent is next used."); } } diff --git a/src/commands/sandbox.test.ts b/src/commands/sandbox.test.ts index 384dc2eef41..7425e712c6f 100644 --- a/src/commands/sandbox.test.ts +++ b/src/commands/sandbox.test.ts @@ -29,10 +29,14 @@ import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js"; const NOW = Date.now(); function createContainer(overrides: Partial = {}): SandboxContainerInfo { + const containerName = overrides.containerName ?? "openclaw-sandbox-test"; return { - containerName: "openclaw-sandbox-test", + containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: "test-session", image: "openclaw/sandbox:latest", + configLabelKind: "Image", imageMatch: true, running: true, createdAtMs: NOW - 3600000, @@ -104,7 +108,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expectLogContains(runtime, "📦 Sandbox Containers"); + expectLogContains(runtime, "📦 Sandbox Runtimes"); expectLogContains(runtime, container1.containerName); expectLogContains(runtime, container2.containerName); expectLogContains(runtime, "Total"); @@ -128,14 +132,14 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); expectLogContains(runtime, "⚠️"); - expectLogContains(runtime, "image mismatch"); + expectLogContains(runtime, "config mismatch"); expectLogContains(runtime, "sandbox recreate --all"); }); it("should display message when no containers found", async () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); @@ -161,7 +165,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); }); @@ -295,7 +299,7 @@ describe("sandboxRecreateCommand", () => { it("should show message when no containers match", async () => { await sandboxRecreateCommand({ all: true, browser: false, force: true }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No containers found matching the criteria."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found matching the criteria."); expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); }); diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index e9071ce7810..d6b494fc5aa 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -74,7 +74,7 @@ export async function sandboxRecreateCommand( const filtered = await fetchAndFilterContainers(opts); if (filtered.containers.length + filtered.browsers.length === 0) { - runtime.log("No containers found matching the criteria."); + runtime.log("No sandbox runtimes found matching the criteria."); return; } @@ -154,7 +154,7 @@ async function removeContainers( filtered: FilteredContainers, runtime: RuntimeEnv, ): Promise<{ successCount: number; failCount: number }> { - runtime.log("\nRemoving containers...\n"); + runtime.log("\nRemoving sandbox runtimes...\n"); let successCount = 0; let failCount = 0; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 152c8973c11..1e398cc1c70 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -15,6 +15,8 @@ export type AgentModelConfig = export type AgentSandboxConfig = { mode?: "off" | "non-main" | "all"; + /** Sandbox runtime backend id. Default: "docker". */ + backend?: string; /** Agent workspace access inside the sandbox. */ workspaceAccess?: "none" | "ro" | "rw"; /** diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d7b1dd393e7..2ee70e58ef6 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -496,6 +496,7 @@ const ToolLoopDetectionSchema = z export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), + backend: z.string().min(1).optional(), workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 4f403343b34..a792af23816 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,7 @@ export type { AnyAgentTool, OpenClawPluginApi, + OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, @@ -25,6 +26,22 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, } from "../plugins/types.js"; +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, +} from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -36,6 +53,12 @@ export type { } from "../infra/provider-usage.types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { applyProviderDefaultModel, From 986b772a89d0fedb4e296545ec7224d19767c740 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:05:19 -0700 Subject: [PATCH 076/331] Status: scope JSON plugin preload to configured channels --- src/channels/config-presence.ts | 64 ++++++++++++++++----- src/cli/plugin-registry.test.ts | 95 ++++++++++++++++++++++++++++++++ src/cli/plugin-registry.ts | 49 ++++++++++++++-- src/commands/status.scan.test.ts | 12 ++-- src/commands/status.scan.ts | 2 +- 5 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 src/cli/plugin-registry.test.ts diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 792aa545a54..d9add345eeb 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -7,19 +7,19 @@ import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const CHANNEL_ENV_PREFIXES = [ - "BLUEBUBBLES_", - "DISCORD_", - "GOOGLECHAT_", - "IRC_", - "LINE_", - "MATRIX_", - "MSTEAMS_", - "SIGNAL_", - "SLACK_", - "TELEGRAM_", - "WHATSAPP_", - "ZALOUSER_", - "ZALO_", + ["BLUEBUBBLES_", "bluebubbles"], + ["DISCORD_", "discord"], + ["GOOGLECHAT_", "googlechat"], + ["IRC_", "irc"], + ["LINE_", "line"], + ["MATRIX_", "matrix"], + ["MSTEAMS_", "msteams"], + ["SIGNAL_", "signal"], + ["SLACK_", "slack"], + ["TELEGRAM_", "telegram"], + ["WHATSAPP_", "whatsapp"], + ["ZALOUSER_", "zalouser"], + ["ZALO_", "zalo"], ] as const; function hasNonEmptyString(value: unknown): boolean { @@ -60,13 +60,49 @@ function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { } } +export function listPotentialConfiguredChannelIds( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const configuredChannelIds = new Set(); + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + configuredChannelIds.add(key); + } + } + } + + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + for (const [prefix, channelId] of CHANNEL_ENV_PREFIXES) { + if (key.startsWith(prefix)) { + configuredChannelIds.add(channelId); + } + } + if (key === "TELEGRAM_BOT_TOKEN") { + configuredChannelIds.add("telegram"); + } + } + if (hasWhatsAppAuthState(env)) { + configuredChannelIds.add("whatsapp"); + } + return [...configuredChannelIds]; +} + function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { continue; } if ( - CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + CHANNEL_ENV_PREFIXES.some(([prefix]) => key.startsWith(prefix)) || key === "TELEGRAM_BOT_TOKEN" ) { return true; diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..f9751d5fed8 --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "main"), + loadConfig: vi.fn(), + loadOpenClawPlugins: vi.fn(), + loadPluginManifestRegistry: vi.fn(), + getActivePluginRegistry: vi.fn(), +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: mocks.getActivePluginRegistry, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ + plugins: { enabled: true }, + channels: { telegram: { enabled: false } }, + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { id: "telegram", channels: ["telegram"] }, + { id: "slack", channels: ["slack"] }, + { id: "openai", channels: [] }, + ], + }); + mocks.getActivePluginRegistry.mockReturnValue({ + plugins: [], + channels: [], + tools: [], + }); + }); + + it("loads only configured channel plugins for configured-channels scope", async () => { + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["telegram"], + }), + ); + }); + + it("reloads when escalating from configured-channels to channels", async () => { + mocks.getActivePluginRegistry + .mockReturnValueOnce({ + plugins: [], + channels: [], + tools: [], + }) + .mockReturnValue({ + plugins: [{ id: "telegram" }], + channels: [{ plugin: { id: "telegram" } }], + tools: [], + }); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + ensurePluginRegistryLoaded({ scope: "channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ onlyPluginIds: ["telegram"] }), + ); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ onlyPluginIds: ["telegram", "slack"] }), + ); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index aad181eff7f..f51a57d7fda 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -7,9 +8,22 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; +let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; -export type PluginRegistryScope = "channels" | "all"; +export type PluginRegistryScope = "configured-channels" | "channels" | "all"; + +function scopeRank(scope: typeof pluginRegistryLoaded): number { + switch (scope) { + case "none": + return 0; + case "configured-channels": + return 1; + case "channels": + return 2; + case "all": + return 3; + } +} function resolveChannelPluginIds(params: { config: ReturnType; @@ -25,15 +39,30 @@ function resolveChannelPluginIds(params: { .map((plugin) => plugin.id); } +function resolveConfiguredChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; - if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { + if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { return; } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid // doing an expensive load when we already have plugins/channels/tools. if ( + pluginRegistryLoaded === "none" && active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { @@ -52,15 +81,23 @@ export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistrySco config, workspaceDir, logger, - ...(scope === "channels" + ...(scope === "configured-channels" ? { - onlyPluginIds: resolveChannelPluginIds({ + onlyPluginIds: resolveConfiguredChannelPluginIds({ config, workspaceDir, env: process.env, }), } - : {}), + : scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); pluginRegistryLoaded = scope; } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 55f323f0b4a..122e10076bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -194,7 +194,7 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); - it("preloads channel plugins for status --json when channel config exists", async () => { + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, plugins: { enabled: false }, @@ -245,7 +245,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); expect(mocks.probeGateway).toHaveBeenCalledWith( expect.objectContaining({ detailLevel: "presence" }), ); @@ -254,7 +256,7 @@ describe("scanStatus", () => { ); }); - it("preloads channel plugins for status --json when channel auth is env-only", async () => { + it("preloads configured channel plugins for status --json when channel auth is env-only", async () => { const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; process.env.MATRIX_ACCESS_TOKEN = "token"; mocks.readBestEffortConfig.mockResolvedValue({ @@ -313,6 +315,8 @@ describe("scanStatus", () => { } } - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 88dd21e7177..7f1380964d5 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -202,7 +202,7 @@ async function scanStatusJsonFast(opts: { }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "channels" }); + ensurePluginRegistryLoaded({ scope: "configured-channels" }); } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; From f71f44576aa0172055ba3d3f0ee86f9c9f6cd99e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:10:43 -0700 Subject: [PATCH 077/331] Status: lazy-load read-only account inspectors --- src/channels/plugins/status.ts | 12 ++--- ...ad-only-account-inspect.discord.runtime.ts | 4 ++ ...read-only-account-inspect.slack.runtime.ts | 4 ++ ...d-only-account-inspect.telegram.runtime.ts | 4 ++ src/channels/read-only-account-inspect.ts | 50 ++++++++++++------- src/commands/channel-account-context.ts | 4 +- src/commands/health.ts | 12 ++--- src/commands/status-all/channels.ts | 14 ++++-- src/infra/channel-summary.ts | 14 ++++-- src/security/audit-channel.ts | 10 ++-- 10 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 src/channels/read-only-account-inspect.discord.runtime.ts create mode 100644 src/channels/read-only-account-inspect.slack.runtime.ts create mode 100644 src/channels/read-only-account-inspect.telegram.runtime.ts diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index 689c50c6710..983ba23be33 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -41,17 +41,17 @@ async function buildSnapshotFromAccount(params: { }; } -function inspectChannelAccount(params: { +async function inspectChannelAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; accountId: string; -}): ResolvedAccount | null { +}): Promise { return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: params.plugin.id, cfg: params.cfg, accountId: params.accountId, - })) as ResolvedAccount | null; + }))) as ResolvedAccount | null; } export async function buildReadOnlySourceChannelAccountSnapshot(params: { @@ -62,7 +62,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); if (!inspectedAccount) { return null; } @@ -80,7 +80,7 @@ export async function buildChannelAccountSnapshot(params: { probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); const account = inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId); return await buildSnapshotFromAccount({ diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts new file mode 100644 index 00000000000..aed3283b7a2 --- /dev/null +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectDiscordAccount, + type InspectedDiscordAccount, +} from "../../extensions/discord/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts new file mode 100644 index 00000000000..6d0e0a10b29 --- /dev/null +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectSlackAccount, + type InspectedSlackAccount, +} from "../../extensions/slack/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts new file mode 100644 index 00000000000..07866b9d450 --- /dev/null +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectTelegramAccount, + type InspectedTelegramAccount, +} from "../../extensions/telegram/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index c8d99a3a42e..d26c1c77f55 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -1,41 +1,55 @@ -import { - inspectDiscordAccount, - type InspectedDiscordAccount, -} from "../../extensions/discord/src/account-inspect.js"; -import { - inspectSlackAccount, - type InspectedSlackAccount, -} from "../../extensions/slack/src/account-inspect.js"; -import { - inspectTelegramAccount, - type InspectedTelegramAccount, -} from "../../extensions/telegram/src/account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelId } from "./plugins/types.js"; -export type ReadOnlyInspectedAccount = - | InspectedDiscordAccount - | InspectedSlackAccount - | InspectedTelegramAccount; +type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js"); +type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js"); +type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.runtime.js"); -export function inspectReadOnlyChannelAccount(params: { +let discordInspectModulePromise: Promise | undefined; +let slackInspectModulePromise: Promise | undefined; +let telegramInspectModulePromise: Promise | undefined; + +function loadDiscordInspectModule() { + discordInspectModulePromise ??= import("./read-only-account-inspect.discord.runtime.js"); + return discordInspectModulePromise; +} + +function loadSlackInspectModule() { + slackInspectModulePromise ??= import("./read-only-account-inspect.slack.runtime.js"); + return slackInspectModulePromise; +} + +function loadTelegramInspectModule() { + telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.runtime.js"); + return telegramInspectModulePromise; +} + +export type ReadOnlyInspectedAccount = + | Awaited> + | Awaited> + | Awaited>; + +export async function inspectReadOnlyChannelAccount(params: { channelId: ChannelId; cfg: OpenClawConfig; accountId?: string | null; -}): ReadOnlyInspectedAccount | null { +}): Promise { if (params.channelId === "discord") { + const { inspectDiscordAccount } = await loadDiscordInspectModule(); return inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "slack") { + const { inspectSlackAccount } = await loadSlackInspectModule(); return inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "telegram") { + const { inspectTelegramAccount } = await loadTelegramInspectModule(); return inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index c997ec3e18a..a9f12974b06 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -79,11 +79,11 @@ export async function resolveDefaultChannelAccountContext( const inspected = plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId: defaultAccountId, - }); + })); let account = inspected; if (!account) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 0e54eebadc7..ddfc308bda4 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -165,18 +165,14 @@ const buildSessionSummary = (storePath: string) => { const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; -function inspectHealthAccount( - plugin: ChannelPlugin, - cfg: OpenClawConfig, - accountId: string, -): unknown { +async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -206,7 +202,7 @@ async function resolveHealthAccountContext(params: { diagnostics.push( `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, ); - account = inspectHealthAccount(params.plugin, params.cfg, params.accountId); + account = await inspectHealthAccount(params.plugin, params.cfg, params.accountId); } if (!account) { diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index cf3a67a99b5..27e0eff43c6 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -91,14 +91,18 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string return `${head}…${tail} · len ${t.length}`; } -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -106,8 +110,8 @@ async function resolveChannelAccountRow( params: ResolvedChannelAccountRowParams, ): Promise { const { plugin, cfg, sourceConfig, accountId } = params; - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, cfg, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 08fd35d9327..d537b5eb317 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -105,14 +105,18 @@ const buildAccountDetails = (params: { return details; }; -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -135,8 +139,8 @@ export async function buildChannelSummary( const entries: ChannelAccountEntry[] = []; for (const accountId of resolvedAccountIds) { - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, effective, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index bf501cf659b..ce1484f6513 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -144,17 +144,17 @@ export async function collectChannelSecurityFindings(params: { const findings: SecurityAuditFinding[] = []; const sourceConfig = params.sourceConfig ?? params.cfg; - const inspectChannelAccount = ( + const inspectChannelAccount = async ( plugin: (typeof params.plugins)[number], cfg: OpenClawConfig, accountId: string, ) => plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }); + })); const asAccountRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) @@ -166,8 +166,8 @@ export async function collectChannelSecurityFindings(params: { accountId: string, ) => { const diagnostics: string[] = []; - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { enabled?: boolean; configured?: boolean; From 8ab01c5c9394d7f85a2b258b0bd9b2824b85ac7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:02:24 -0700 Subject: [PATCH 078/331] refactor(core): land plugin auth and startup cleanup --- docs/concepts/model-providers.md | 2 + docs/plugins/manifest.md | 6 + docs/tools/plugin.md | 7 ++ extensions/anthropic/openclaw.plugin.json | 3 + extensions/byteplus/openclaw.plugin.json | 3 + .../openclaw.plugin.json | 3 + extensions/feishu/src/onboarding.ts | 7 ++ extensions/github-copilot/index.ts | 7 +- .../github-copilot/openclaw.plugin.json | 3 + .../github-copilot/token.test.ts | 7 +- .../github-copilot/token.ts | 4 +- .../github-copilot/usage.test.ts | 7 +- .../github-copilot/usage.ts | 9 +- extensions/huggingface/openclaw.plugin.json | 3 + extensions/kilocode/openclaw.plugin.json | 3 + extensions/kimi-coding/openclaw.plugin.json | 3 + extensions/mattermost/src/setup-surface.ts | 2 +- extensions/minimax/index.ts | 1 + extensions/minimax/openclaw.plugin.json | 4 + extensions/mistral/openclaw.plugin.json | 3 + extensions/modelstudio/openclaw.plugin.json | 3 + extensions/moonshot/openclaw.plugin.json | 3 + extensions/nvidia/openclaw.plugin.json | 3 + extensions/ollama/openclaw.plugin.json | 3 + extensions/openai/openclaw.plugin.json | 3 + extensions/opencode-go/openclaw.plugin.json | 3 + extensions/opencode/openclaw.plugin.json | 3 + extensions/openrouter/openclaw.plugin.json | 3 + extensions/qianfan/openclaw.plugin.json | 3 + extensions/qwen-portal-auth/index.ts | 1 + .../qwen-portal-auth/openclaw.plugin.json | 3 + extensions/sglang/openclaw.plugin.json | 3 + extensions/synthetic/openclaw.plugin.json | 3 + extensions/together/openclaw.plugin.json | 3 + extensions/venice/openclaw.plugin.json | 3 + .../vercel-ai-gateway/openclaw.plugin.json | 3 + extensions/vllm/openclaw.plugin.json | 3 + extensions/volcengine/openclaw.plugin.json | 3 + extensions/xiaomi/openclaw.plugin.json | 3 + extensions/zai/openclaw.plugin.json | 3 + src/agents/model-auth-env-vars.ts | 49 ++------- src/agents/model-auth.profiles.test.ts | 41 +++++++ src/agents/model-auth.ts | 4 +- ...fault-baseurl-token-exchange-fails.test.ts | 2 +- ...pi-agent.auth-profile-rotation.e2e.test.ts | 2 +- src/channels/plugins/onboarding-types.ts | 7 +- src/commands/channel-setup/registry.ts | 9 +- src/commands/channels/add.ts | 3 +- src/commands/onboard-channels.ts | 22 ++-- src/daemon/program-args.test.ts | 32 ++++++ src/daemon/program-args.ts | 6 +- src/index.test.ts | 46 ++++++++ src/index.ts | 94 +++++----------- src/infra/gateway-process-argv.test.ts | 1 + src/infra/gateway-process-argv.ts | 1 + src/infra/provider-usage.auth.ts | 103 ------------------ src/infra/provider-usage.fetch.ts | 1 - src/infra/provider-usage.load.plugin.test.ts | 2 +- src/infra/provider-usage.load.ts | 52 +-------- src/library.ts | 48 ++++++++ .../bundled-provider-auth-env-vars.test.ts | 22 ++++ src/plugins/bundled-provider-auth-env-vars.ts | 91 ++++++++++++++++ src/plugins/config-state.test.ts | 5 + src/plugins/config-state.ts | 1 + src/plugins/loader.ts | 98 ++++++++++++----- src/plugins/manifest-registry.test.ts | 22 ++++ src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 22 ++++ src/plugins/provider-runtime.test.ts | 13 ++- src/plugins/provider-runtime.ts | 14 ++- src/plugins/providers.test.ts | 26 ++++- src/plugins/providers.ts | 28 +++++ src/plugins/types.ts | 15 ++- src/secrets/provider-env-vars.test.ts | 6 +- src/secrets/provider-env-vars.ts | 84 +++++++------- 75 files changed, 736 insertions(+), 383 deletions(-) create mode 100644 extensions/feishu/src/onboarding.ts rename src/providers/github-copilot-token.test.ts => extensions/github-copilot/token.test.ts (91%) rename src/providers/github-copilot-token.ts => extensions/github-copilot/token.ts (97%) rename src/infra/provider-usage.fetch.copilot.test.ts => extensions/github-copilot/usage.test.ts (93%) rename src/infra/provider-usage.fetch.copilot.ts => extensions/github-copilot/usage.ts (83%) create mode 100644 src/index.test.ts create mode 100644 src/library.ts create mode 100644 src/plugins/bundled-provider-auth-env-vars.test.ts create mode 100644 src/plugins/bundled-provider-auth-env-vars.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 23fe7edcd1d..aa4b90fd41f 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -19,6 +19,8 @@ For model selection rules, see [/concepts/models](/concepts/models). - Provider plugins can inject model catalogs via `registerProvider({ catalog })`; OpenClaw merges that output into `models.providers` before writing `models.json`. +- Provider manifests can declare `providerAuthEnvVars` so generic env-based + auth probes do not need to load plugin runtime. - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 9c266744b71..01d5e0d3578 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -56,6 +56,9 @@ Optional keys: - `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`). - `channels` (array): channel ids registered by this plugin (example: `["matrix"]`). - `providers` (array): provider ids registered by this plugin. +- `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this + when OpenClaw should resolve provider credentials from env without loading + plugin runtime first. - `skills` (array): skill directories to load (relative to the plugin root). - `name` (string): display name for the plugin. - `description` (string): short plugin summary. @@ -84,6 +87,9 @@ Optional keys: - The manifest is **required for native OpenClaw plugins**, including local filesystem loads. - Runtime still loads the plugin module separately; the manifest is only for discovery + validation. +- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker + validation, and similar provider-auth surfaces that should not boot plugin + runtime just to inspect env names. - Exclusive plugin kinds are selected through `plugins.slots.*`. - `kind: "memory"` is selected by `plugins.slots.memory`. - `kind: "context-engine"` is selected by `plugins.slots.contextEngine` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3987ff6a7eb..976c10d0671 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -217,6 +217,8 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: +- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before + runtime load - config-time hooks: `catalog` / legacy `discovery` - runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` @@ -224,6 +226,11 @@ OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without needing a whole custom inference transport. +Use manifest `providerAuthEnvVars` when the provider has env-based credentials +that generic auth/status/model-picker paths should see without loading plugin +runtime. Keep provider runtime `envVars` for operator-facing hints such as +onboarding labels or OAuth client-id/client-secret setup vars. + ### Hook order For model/provider plugins, OpenClaw uses hooks in this rough order: diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 5342e849e52..aec972801f8 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "anthropic", "providers": ["anthropic"], + "providerAuthEnvVars": { + "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 8885280bf32..abef4351a48 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "byteplus", "providers": ["byteplus", "byteplus-plan"], + "providerAuthEnvVars": { + "byteplus": ["BYTEPLUS_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index fc7a41f77bb..ca7810e1fd2 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "cloudflare-ai-gateway", "providers": ["cloudflare-ai-gateway"], + "providerAuthEnvVars": { + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 00000000000..ff8f563cf65 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -0,0 +1,7 @@ +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { feishuPlugin } from "./channel.js"; + +export const feishuOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 19114472830..038ed70aec9 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -8,11 +8,8 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; -import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js"; -import { - DEFAULT_COPILOT_API_BASE_URL, - resolveCopilotApiToken, -} from "../../src/providers/github-copilot-token.js"; +import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; +import { fetchCopilotUsage } from "./usage.js"; const PROVIDER_ID = "github-copilot"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index ec3f8690eee..a6cb5b7f4b5 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "github-copilot", "providers": ["github-copilot"], + "providerAuthEnvVars": { + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/providers/github-copilot-token.test.ts b/extensions/github-copilot/token.test.ts similarity index 91% rename from src/providers/github-copilot-token.test.ts rename to extensions/github-copilot/token.test.ts index 4f7664364a0..8aa489e7a8b 100644 --- a/src/providers/github-copilot-token.test.ts +++ b/extensions/github-copilot/token.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - deriveCopilotApiBaseUrlFromToken, - resolveCopilotApiToken, -} from "./github-copilot-token.js"; +import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js"; describe("github-copilot token", () => { const loadJsonFile = vi.fn(); @@ -58,7 +55,7 @@ describe("github-copilot token", () => { }), }); - const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("./token.js"); const res = await resolveCopilotApiToken({ githubToken: "gh", diff --git a/src/providers/github-copilot-token.ts b/extensions/github-copilot/token.ts similarity index 97% rename from src/providers/github-copilot-token.ts rename to extensions/github-copilot/token.ts index a5d9a6b1e8e..afb1eb03b61 100644 --- a/src/providers/github-copilot-token.ts +++ b/extensions/github-copilot/token.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { resolveStateDir } from "../../src/config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../../src/infra/json-file.js"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; diff --git a/src/infra/provider-usage.fetch.copilot.test.ts b/extensions/github-copilot/usage.test.ts similarity index 93% rename from src/infra/provider-usage.fetch.copilot.test.ts rename to extensions/github-copilot/usage.test.ts index 0abfd5f782f..b4044c7f5f9 100644 --- a/src/infra/provider-usage.fetch.copilot.test.ts +++ b/extensions/github-copilot/usage.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; -import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; +import { fetchCopilotUsage } from "./usage.js"; describe("fetchCopilotUsage", () => { it("returns HTTP errors for failed requests", async () => { diff --git a/src/infra/provider-usage.fetch.copilot.ts b/extensions/github-copilot/usage.ts similarity index 83% rename from src/infra/provider-usage.fetch.copilot.ts rename to extensions/github-copilot/usage.ts index 40d4adcd3aa..9035027890c 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/extensions/github-copilot/usage.ts @@ -1,6 +1,9 @@ -import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +import { + buildUsageHttpErrorSnapshot, + fetchJson, +} from "../../src/infra/provider-usage.fetch.shared.js"; +import { clampPercent, PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; +import type { ProviderUsageSnapshot, UsageWindow } from "../../src/infra/provider-usage.types.js"; type CopilotUsageResponse = { quota_snapshots?: { diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 4b68bcedb26..67a34124d0a 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "huggingface", "providers": ["huggingface"], + "providerAuthEnvVars": { + "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index ec078c33ab7..6e3e39aec27 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kilocode", "providers": ["kilocode"], + "providerAuthEnvVars": { + "kilocode": ["KILOCODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index 8874fb6501b..0664e7ae6df 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kimi-coding", "providers": ["kimi-coding"], + "providerAuthEnvVars": { + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index e1be50e662a..13b69542d02 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,6 +1,6 @@ import { - DEFAULT_ACCOUNT_ID, applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 969868986f0..e99f5bf15b2 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -175,6 +175,7 @@ const minimaxPlugin = { id: PORTAL_PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], catalog: { run: async (ctx) => resolvePortalCatalog(ctx), }, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 32d8be58bf5..8934580b36b 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -1,6 +1,10 @@ { "id": "minimax", "providers": ["minimax", "minimax-portal"], + "providerAuthEnvVars": { + "minimax": ["MINIMAX_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index dd38282811b..480c09417d0 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "mistral", "providers": ["mistral"], + "providerAuthEnvVars": { + "mistral": ["MISTRAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/modelstudio/openclaw.plugin.json b/extensions/modelstudio/openclaw.plugin.json index 1a8d9e71c75..5cc87ad1b54 100644 --- a/extensions/modelstudio/openclaw.plugin.json +++ b/extensions/modelstudio/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "modelstudio", "providers": ["modelstudio"], + "providerAuthEnvVars": { + "modelstudio": ["MODELSTUDIO_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index e02cb3d21c5..542ae46fead 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "moonshot", "providers": ["moonshot"], + "providerAuthEnvVars": { + "moonshot": ["MOONSHOT_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 268bfa2dafd..3b46534911b 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "nvidia", "providers": ["nvidia"], + "providerAuthEnvVars": { + "nvidia": ["NVIDIA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 3df1002d1ac..b644e105b84 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "ollama", "providers": ["ollama"], + "providerAuthEnvVars": { + "ollama": ["OLLAMA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 480e80a59ce..4b0ae0efc31 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openai", "providers": ["openai", "openai-codex"], + "providerAuthEnvVars": { + "openai": ["OPENAI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 09d48bcf314..d264f4acdb6 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "opencode-go", "providers": ["opencode-go"], + "providerAuthEnvVars": { + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index f61e9b99b67..68608e6abd1 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "opencode", "providers": ["opencode"], + "providerAuthEnvVars": { + "opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index 7e7840cb1c9..84069b8129b 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openrouter", "providers": ["openrouter"], + "providerAuthEnvVars": { + "openrouter": ["OPENROUTER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 9bd75d78c4b..5070b7a65b7 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qianfan", "providers": ["qianfan"], + "providerAuthEnvVars": { + "qianfan": ["QIANFAN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 919fa927e57..446070b0a6b 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -94,6 +94,7 @@ const qwenPortalPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/qwen", aliases: ["qwen"], + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], catalog: { run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), }, diff --git a/extensions/qwen-portal-auth/openclaw.plugin.json b/extensions/qwen-portal-auth/openclaw.plugin.json index be200d11f04..1f5a5deb0b5 100644 --- a/extensions/qwen-portal-auth/openclaw.plugin.json +++ b/extensions/qwen-portal-auth/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qwen-portal-auth", "providers": ["qwen-portal"], + "providerAuthEnvVars": { + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 161ea4c635a..8d5840c0fdf 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "sglang", "providers": ["sglang"], + "providerAuthEnvVars": { + "sglang": ["SGLANG_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index fab1326ca34..54c12a19e4c 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "synthetic", "providers": ["synthetic"], + "providerAuthEnvVars": { + "synthetic": ["SYNTHETIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index 2a868251f34..ea3ae237fa2 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "together", "providers": ["together"], + "providerAuthEnvVars": { + "together": ["TOGETHER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index 6262595509e..a84a0e7b669 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "venice", "providers": ["venice"], + "providerAuthEnvVars": { + "venice": ["VENICE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 14f4a214605..47037724c36 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vercel-ai-gateway", "providers": ["vercel-ai-gateway"], + "providerAuthEnvVars": { + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 5a9f9a778ee..6ab01cb5e89 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vllm", "providers": ["vllm"], + "providerAuthEnvVars": { + "vllm": ["VLLM_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index 0773577aef9..2b5e54ff013 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "volcengine", "providers": ["volcengine", "volcengine-plan"], + "providerAuthEnvVars": { + "volcengine": ["VOLCANO_ENGINE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 78c758c6571..4f0c03c280f 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "xiaomi", "providers": ["xiaomi"], + "providerAuthEnvVars": { + "xiaomi": ["XIAOMI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 5e23160ddb6..c5985d748b0 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "zai", "providers": ["zai"], + "providerAuthEnvVars": { + "zai": ["ZAI_API_KEY", "Z_AI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index c9cb9159138..e318cd2e9c8 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -1,45 +1,10 @@ -export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { - "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], - "byteplus-plan": ["BYTEPLUS_API_KEY"], - "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - openai: ["OPENAI_API_KEY"], - google: ["GEMINI_API_KEY"], - voyage: ["VOYAGE_API_KEY"], - groq: ["GROQ_API_KEY"], - deepgram: ["DEEPGRAM_API_KEY"], - cerebras: ["CEREBRAS_API_KEY"], - xai: ["XAI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - nvidia: ["NVIDIA_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - together: ["TOGETHER_API_KEY"], - qianfan: ["QIANFAN_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - ollama: ["OLLAMA_API_KEY"], - sglang: ["SGLANG_API_KEY"], - vllm: ["VLLM_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], -}; +import { + PROVIDER_AUTH_ENV_VAR_CANDIDATES, + listKnownProviderAuthEnvVarNames, +} from "../secrets/provider-env-vars.js"; + +export const PROVIDER_ENV_API_KEY_CANDIDATES = PROVIDER_AUTH_ENV_VAR_CANDIDATES; export function listKnownProviderEnvApiKeyNames(): string[] { - return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; + return listKnownProviderAuthEnvVarNames(); } diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index a1fc511aaf8..ca509f632d4 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -426,4 +426,45 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('qwen-portal') accepts QWEN_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + QWEN_OAUTH_TOKEN: "qwen-oauth-token", + QWEN_PORTAL_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("qwen"); + expect(resolved?.apiKey).toBe("qwen-oauth-token"); + expect(resolved?.source).toContain("QWEN_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + MINIMAX_OAUTH_TOKEN: "minimax-oauth-token", + MINIMAX_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("minimax-portal"); + expect(resolved?.apiKey).toBe("minimax-oauth-token"); + expect(resolved?.source).toContain("MINIMAX_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => { + await withEnvAsync( + { + VOLCANO_ENGINE_API_KEY: "volcengine-plan-key", + }, + async () => { + const resolved = resolveEnvApiKey("volcengine-plan"); + expect(resolved?.apiKey).toBe("volcengine-plan-key"); + expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY"); + }, + ); + }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 0616bc41194..4a896d5b56b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -25,7 +25,7 @@ import { isNonSecretApiKeyMarker, OLLAMA_LOCAL_AUTH_MARKER, } from "./model-auth-markers.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -400,7 +400,7 @@ export function resolveEnvApiKey( provider: string, env: NodeJS.ProcessEnv = process.env, ): EnvApiKeyResult | null { - const normalized = normalizeProviderId(provider); + const normalized = normalizeProviderIdForAuth(provider); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { const value = normalizeOptionalSecretInput(env[envVar]); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index ed4b0a7100c..efcba001638 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js"; +import { DEFAULT_COPILOT_API_BASE_URL } from "../../extensions/github-copilot/token.js"; import { withEnvAsync } from "../test-utils/env.js"; import { installModelsConfigTestHooks, diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index f9f9934f453..cbea9e5f21b 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -33,7 +33,7 @@ vi.mock("../infra/backoff.js", () => ({ sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), })); -vi.mock("../providers/github-copilot-token.js", () => ({ +vi.mock("../../extensions/github-copilot/token.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), })); diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index f560b27b172..8562e6b06a6 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -4,13 +4,18 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; +export type ChannelOnboardingSetupPlugin = Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" +>; + export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; - onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/commands/channel-setup/registry.ts b/src/commands/channel-setup/registry.ts index 576d7e14b60..bedc2f9bf6d 100644 --- a/src/commands/channel-setup/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -25,10 +25,15 @@ const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ linePlugin, ]; +export type ChannelOnboardingSetupPlugin = Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" +>; + const setupWizardAdapters = new WeakMap(); export function resolveChannelOnboardingAdapterForPlugin( - plugin?: ChannelPlugin, + plugin?: ChannelOnboardingSetupPlugin, ): ChannelOnboardingAdapter | undefined { if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); @@ -74,7 +79,7 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { export async function loadBundledChannelOnboardingPlugin( channel: ChannelChoice, -): Promise { +): Promise { switch (channel) { case "discord": return discordPlugin as ChannelPlugin; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 0c9b5b15e56..30fe44f1b54 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/onboarding-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; @@ -56,7 +57,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; - const resolvedPlugins = new Map(); + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 103f81cbff9..ffc4932f7b8 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,11 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/onboarding-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -94,7 +94,7 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; - plugin?: ChannelPlugin; + plugin?: ChannelOnboardingSetupPlugin; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = params.plugin ?? getChannelSetupPlugin(channel); @@ -121,7 +121,7 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ChannelPlugin[]; + installedPlugins?: ChannelOnboardingSetupPlugin[]; resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); @@ -279,7 +279,7 @@ async function maybeConfigureDmPolicies(params: { const { selection, prompter, accountIdsByChannel } = params; const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection - .map((channel) => resolve(channel)?.dmPolicy) + .map((channel) => resolve?.(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; @@ -350,17 +350,19 @@ export async function setupChannels( const accountOverrides: Partial> = { ...options?.accountIds, }; - const scopedPluginsById = new Map(); + const scopedPluginsById = new Map(); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - const rememberScopedPlugin = (plugin: ChannelPlugin) => { + const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; - const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined => + const getVisibleChannelPlugin = ( + channel: ChannelChoice, + ): ChannelOnboardingSetupPlugin | undefined => scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelPlugin[] => { - const merged = new Map(); + const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => { + const merged = new Map(); for (const plugin of listChannelSetupPlugins()) { merged.set(plugin.id, plugin); } @@ -372,7 +374,7 @@ export async function setupChannels( const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 68dc4edb71c..920f4533297 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +const childProcessMocks = vi.hoisted(() => ({ + execFileSync: vi.fn(), +})); + const fsMocks = vi.hoisted(() => ({ access: vi.fn(), realpath: vi.fn(), @@ -12,6 +16,10 @@ vi.mock("node:fs/promises", () => ({ realpath: fsMocks.realpath, })); +vi.mock("node:child_process", () => ({ + execFileSync: childProcessMocks.execFileSync, +})); + import { resolveGatewayProgramArguments } from "./program-args.js"; const originalArgv = [...process.argv]; @@ -87,4 +95,28 @@ describe("resolveGatewayProgramArguments", () => { "18789", ]); }); + + it("uses src/entry.ts for bun dev mode", async () => { + const repoIndexPath = path.resolve("/repo/src/index.ts"); + const repoEntryPath = path.resolve("/repo/src/entry.ts"); + process.argv = ["/usr/local/bin/node", repoIndexPath]; + fsMocks.realpath.mockResolvedValue(repoIndexPath); + fsMocks.access.mockResolvedValue(undefined); + childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n"); + + const result = await resolveGatewayProgramArguments({ + dev: true, + port: 18789, + runtime: "bun", + }); + + expect(result.programArguments).toEqual([ + "/usr/local/bin/bun", + repoEntryPath, + "gateway", + "--port", + "18789", + ]); + expect(result.workingDirectory).toBe(path.resolve("/repo")); + }); }); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 76bad8fc1ce..9e60f26f761 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -123,7 +123,7 @@ function resolveRepoRootForDev(): string { const parts = normalized.split(path.sep); const srcIndex = parts.lastIndexOf("src"); if (srcIndex === -1) { - throw new Error("Dev mode requires running from repo (src/index.ts)"); + throw new Error("Dev mode requires running from repo (src/entry.ts)"); } return parts.slice(0, srcIndex).join(path.sep); } @@ -180,7 +180,7 @@ async function resolveCliProgramArguments(params: { if (runtime === "bun") { if (params.dev) { const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); return { @@ -213,7 +213,7 @@ async function resolveCliProgramArguments(params: { // Dev mode: use bun to run TypeScript directly const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); // If already running under bun, use current execPath diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000000..d53d492c527 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const runtimeMocks = vi.hoisted(() => ({ + runCli: vi.fn(async () => {}), +})); + +vi.mock("./cli/run-main.js", () => ({ + runCli: runtimeMocks.runCli, +})); + +describe("legacy root entry", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("routes the package root export to the pure library entry", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { + exports?: Record; + main?: string; + }; + + expect(packageJson.main).toBe("dist/index.js"); + expect(packageJson.exports?.["."]).toBe("./dist/index.js"); + }); + + it("does not run CLI bootstrap when imported as a library dependency", async () => { + const mod = await import("./index.js"); + + expect(typeof mod.runLegacyCliEntry).toBe("function"); + expect(runtimeMocks.runCli).not.toHaveBeenCalled(); + }); + + it("delegates legacy direct-entry execution to run-main", async () => { + const mod = await import("./index.js"); + const argv = ["node", "dist/index.js", "status"]; + + await mod.runLegacyCliEntry(argv); + + expect(runtimeMocks.runCli).toHaveBeenCalledOnce(); + expect(runtimeMocks.runCli).toHaveBeenCalledWith(argv); + }); +}); diff --git a/src/index.ts b/src/index.ts index 61d96ccee33..4daf6521df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,76 +1,40 @@ #!/usr/bin/env node import process from "node:process"; import { fileURLToPath } from "node:url"; -import { getReplyFromConfig } from "./auto-reply/reply.js"; -import { applyTemplate } from "./auto-reply/templating.js"; -import { monitorWebChannel } from "./channel-web.js"; -import { createDefaultDeps } from "./cli/deps.js"; -import { promptYesNo } from "./cli/prompt.js"; -import { waitForever } from "./cli/wait.js"; -import { loadConfig } from "./config/config.js"; -import { - deriveSessionKey, - loadSessionStore, - resolveSessionKey, - resolveStorePath, - saveSessionStore, -} from "./config/sessions.js"; -import { ensureBinary } from "./infra/binaries.js"; -import { loadDotEnv } from "./infra/dotenv.js"; -import { normalizeEnv } from "./infra/env.js"; import { formatUncaughtError } from "./infra/errors.js"; import { isMainModule } from "./infra/is-main.js"; -import { ensureOpenClawCliOnPath } from "./infra/path-env.js"; -import { - describePortOwner, - ensurePortAvailable, - handlePortError, - PortInUseError, -} from "./infra/ports.js"; -import { assertSupportedRuntime } from "./infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; -import { enableConsoleCapture } from "./logging.js"; -import { runCommandWithTimeout, runExec } from "./process/exec.js"; -import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; -loadDotEnv({ quiet: true }); -normalizeEnv(); -ensureOpenClawCliOnPath(); +const library = await import("./library.js"); -// Capture all console output into structured logs while keeping stdout/stderr behavior. -enableConsoleCapture(); +export const assertWebChannel = library.assertWebChannel; +export const applyTemplate = library.applyTemplate; +export const createDefaultDeps = library.createDefaultDeps; +export const deriveSessionKey = library.deriveSessionKey; +export const describePortOwner = library.describePortOwner; +export const ensureBinary = library.ensureBinary; +export const ensurePortAvailable = library.ensurePortAvailable; +export const getReplyFromConfig = library.getReplyFromConfig; +export const handlePortError = library.handlePortError; +export const loadConfig = library.loadConfig; +export const loadSessionStore = library.loadSessionStore; +export const monitorWebChannel = library.monitorWebChannel; +export const normalizeE164 = library.normalizeE164; +export const PortInUseError = library.PortInUseError; +export const promptYesNo = library.promptYesNo; +export const resolveSessionKey = library.resolveSessionKey; +export const resolveStorePath = library.resolveStorePath; +export const runCommandWithTimeout = library.runCommandWithTimeout; +export const runExec = library.runExec; +export const saveSessionStore = library.saveSessionStore; +export const toWhatsappJid = library.toWhatsappJid; +export const waitForever = library.waitForever; -// Enforce the minimum supported runtime before doing any work. -assertSupportedRuntime(); - -import { buildProgram } from "./cli/program.js"; - -const program = buildProgram(); - -export { - assertWebChannel, - applyTemplate, - createDefaultDeps, - deriveSessionKey, - describePortOwner, - ensureBinary, - ensurePortAvailable, - getReplyFromConfig, - handlePortError, - loadConfig, - loadSessionStore, - monitorWebChannel, - normalizeE164, - PortInUseError, - promptYesNo, - resolveSessionKey, - resolveStorePath, - runCommandWithTimeout, - runExec, - saveSessionStore, - toWhatsappJid, - waitForever, -}; +// Legacy direct file entrypoint only. Package root exports now live in library.ts. +export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { + const { runCli } = await import("./cli/run-main.js"); + await runCli(argv); +} const isMain = isMainModule({ currentFile: fileURLToPath(import.meta.url), @@ -86,7 +50,7 @@ if (isMain) { process.exit(1); }); - void program.parseAsync(process.argv).catch((err) => { + void runLegacyCliEntry(process.argv).catch((err) => { console.error("[openclaw] CLI failed:", formatUncaughtError(err)); process.exit(1); }); diff --git a/src/infra/gateway-process-argv.test.ts b/src/infra/gateway-process-argv.test.ts index 81e6da2210a..8f072a80ca6 100644 --- a/src/infra/gateway-process-argv.test.ts +++ b/src/infra/gateway-process-argv.test.ts @@ -26,6 +26,7 @@ describe("isGatewayArgv", () => { expect(isGatewayArgv(["NODE", "C:\\OpenClaw\\DIST\\ENTRY.JS", "gateway"])).toBe(true); expect(isGatewayArgv(["bun", "/srv/openclaw/scripts/run-node.mjs", "gateway"])).toBe(true); expect(isGatewayArgv(["node", "/srv/openclaw/openclaw.mjs", "gateway"])).toBe(true); + expect(isGatewayArgv(["tsx", "/srv/openclaw/src/entry.ts", "gateway"])).toBe(true); expect(isGatewayArgv(["tsx", "/srv/openclaw/src/index.ts", "gateway"])).toBe(true); }); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts index 59f042ead88..47eab54fce2 100644 --- a/src/infra/gateway-process-argv.ts +++ b/src/infra/gateway-process-argv.ts @@ -20,6 +20,7 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool "dist/entry.js", "openclaw.mjs", "scripts/run-node.mjs", + "src/entry.ts", "src/index.ts", ]; if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index a3981fe5f32..00bba63f2e1 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { dedupeProfileIds, ensureAuthProfileStore, @@ -14,7 +11,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import { resolveRequiredHomeDir } from "./home-dir.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -32,46 +28,6 @@ type UsageAuthState = { agentDir?: string; }; -const LEGACY_OAUTH_USAGE_PROVIDERS = new Set([ - "anthropic", - "github-copilot", - "google-gemini-cli", - "openai-codex", -]); - -function parseGoogleToken(apiKey: string): { token: string } | null { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (parsed && typeof parsed.token === "string") { - return { token: parsed.token }; - } - } catch { - // ignore - } - return null; -} - -function resolveLegacyZaiApiKey(state: UsageAuthState): string | undefined { - try { - const authPath = path.join( - resolveRequiredHomeDir(state.env, os.homedir), - ".pi", - "agent", - "auth.json", - ); - if (!fs.existsSync(authPath)) { - return undefined; - } - const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< - string, - { access?: string } - >; - return data["z-ai"]?.access || data.zai?.access; - } catch { - return undefined; - } -} - function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -236,66 +192,7 @@ export async function resolveProviderAuths(params: { }); if (pluginAuth) { auths.push(pluginAuth); - continue; } - - if (provider === "zai") { - const apiKey = - resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["zai", "z-ai"], - envDirect: [state.env.ZAI_API_KEY, state.env.Z_AI_API_KEY], - }) ?? resolveLegacyZaiApiKey(state); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "minimax") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["minimax"], - envDirect: [state.env.MINIMAX_CODE_PLAN_KEY, state.env.MINIMAX_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "xiaomi") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["xiaomi"], - envDirect: [state.env.XIAOMI_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (!LEGACY_OAUTH_USAGE_PROVIDERS.has(provider)) { - continue; - } - - const auth = await resolveOAuthToken({ - state, - provider, - }); - if (!auth) { - continue; - } - if (provider === "google-gemini-cli") { - const parsed = parseGoogleToken(auth.token); - auths.push({ - ...auth, - token: parsed?.token ?? auth.token, - }); - continue; - } - auths.push(auth); } return auths; diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index e0bcd60c94b..87f216eef24 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,6 +1,5 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; -export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index cf78ac667da..55cff6cad72 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -22,7 +22,7 @@ describe("provider-usage.load plugin seam", () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); }); - it("prefers plugin-owned usage snapshots before the legacy core switch", async () => { + it("prefers plugin-owned usage snapshots", async () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ provider: "github-copilot", displayName: "Copilot", diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 9b50285c64f..d34c55c22d3 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -2,14 +2,6 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; -import { - fetchClaudeUsage, - fetchCodexUsage, - fetchCopilotUsage, - fetchGeminiUsage, - fetchMinimaxUsage, - fetchZaiUsage, -} from "./provider-usage.fetch.js"; import { DEFAULT_TIMEOUT_MS, ignoredErrors, @@ -64,44 +56,12 @@ async function fetchProviderUsageSnapshot(params: { if (pluginSnapshot) { return pluginSnapshot; } - - switch (params.auth.provider) { - case "anthropic": - return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "github-copilot": - return await fetchCopilotUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "google-gemini-cli": - return await fetchGeminiUsage( - params.auth.token, - params.timeoutMs, - params.fetchFn, - params.auth.provider, - ); - case "openai-codex": - return await fetchCodexUsage( - params.auth.token, - params.auth.accountId, - params.timeoutMs, - params.fetchFn, - ); - case "minimax": - return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "xiaomi": - return { - provider: "xiaomi", - displayName: PROVIDER_LABELS.xiaomi, - windows: [], - }; - case "zai": - return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); - default: - return { - provider: params.auth.provider, - displayName: PROVIDER_LABELS[params.auth.provider], - windows: [], - error: "Unsupported provider", - }; - } + return { + provider: params.auth.provider, + displayName: PROVIDER_LABELS[params.auth.provider], + windows: [], + error: "Unsupported provider", + }; } export async function loadProviderUsageSummary( diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 00000000000..faaf7ea5998 --- /dev/null +++ b/src/library.ts @@ -0,0 +1,48 @@ +import { getReplyFromConfig } from "./auto-reply/reply.js"; +import { applyTemplate } from "./auto-reply/templating.js"; +import { monitorWebChannel } from "./channel-web.js"; +import { createDefaultDeps } from "./cli/deps.js"; +import { promptYesNo } from "./cli/prompt.js"; +import { waitForever } from "./cli/wait.js"; +import { loadConfig } from "./config/config.js"; +import { + deriveSessionKey, + loadSessionStore, + resolveSessionKey, + resolveStorePath, + saveSessionStore, +} from "./config/sessions.js"; +import { ensureBinary } from "./infra/binaries.js"; +import { + describePortOwner, + ensurePortAvailable, + handlePortError, + PortInUseError, +} from "./infra/ports.js"; +import { runCommandWithTimeout, runExec } from "./process/exec.js"; +import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; + +export { + assertWebChannel, + applyTemplate, + createDefaultDeps, + deriveSessionKey, + describePortOwner, + ensureBinary, + ensurePortAvailable, + getReplyFromConfig, + handlePortError, + loadConfig, + loadSessionStore, + monitorWebChannel, + normalizeE164, + PortInUseError, + promptYesNo, + resolveSessionKey, + resolveStorePath, + runCommandWithTimeout, + runExec, + saveSessionStore, + toWhatsappJid, + waitForever, +}; diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts new file mode 100644 index 00000000000..81523392e7a --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; + +describe("bundled provider auth env vars", () => { + it("reads bundled provider auth env vars from plugin manifests", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([ + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([ + "MINIMAX_OAUTH_TOKEN", + "MINIMAX_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined(); + }); +}); diff --git a/src/plugins/bundled-provider-auth-env-vars.ts b/src/plugins/bundled-provider-auth-env-vars.ts new file mode 100644 index 00000000000..5c152de0566 --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.ts @@ -0,0 +1,91 @@ +import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" }; +import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" }; +import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" }; +import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" }; +import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" }; +import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" }; +import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" }; +import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" }; +import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" }; +import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" }; +import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" }; +import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" }; +import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" }; +import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" }; +import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" }; +import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" }; +import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" }; +import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" }; +import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" }; +import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" }; +import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" }; +import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" }; +import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" }; +import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" }; +import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" }; +import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" }; + +type ProviderAuthEnvVarManifest = { + id?: string; + providerAuthEnvVars?: Record; +}; + +function collectBundledProviderAuthEnvVars( + manifests: readonly ProviderAuthEnvVarManifest[], +): Record { + const entries: Record = {}; + for (const manifest of manifests) { + const providerAuthEnvVars = manifest.providerAuthEnvVars; + if (!providerAuthEnvVars) { + continue; + } + for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) { + const normalizedProviderId = providerId.trim(); + const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean); + if (!normalizedProviderId || normalizedEnvVars.length === 0) { + continue; + } + entries[normalizedProviderId] = normalizedEnvVars; + } + } + return entries; +} + +// Read bundled provider auth env metadata from manifests so env-based auth +// lookup stays cheap and does not need to boot plugin runtime code. +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([ + ANTHROPIC_MANIFEST, + BYTEPLUS_MANIFEST, + CLOUDFLARE_AI_GATEWAY_MANIFEST, + COPILOT_PROXY_MANIFEST, + GITHUB_COPILOT_MANIFEST, + GOOGLE_MANIFEST, + HUGGINGFACE_MANIFEST, + KILOCODE_MANIFEST, + KIMI_CODING_MANIFEST, + MINIMAX_MANIFEST, + MISTRAL_MANIFEST, + MODELSTUDIO_MANIFEST, + MOONSHOT_MANIFEST, + NVIDIA_MANIFEST, + OLLAMA_MANIFEST, + OPENAI_MANIFEST, + OPENCODE_GO_MANIFEST, + OPENCODE_MANIFEST, + OPENROUTER_MANIFEST, + QIANFAN_MANIFEST, + QWEN_PORTAL_AUTH_MANIFEST, + SGLANG_MANIFEST, + SYNTHETIC_MANIFEST, + TOGETHER_MANIFEST, + VENICE_MANIFEST, + VERCEL_AI_GATEWAY_MANIFEST, + VLLM_MANIFEST, + VOLCENGINE_MANIFEST, + XIAOMI_MANIFEST, + ZAI_MANIFEST, +]); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index c4195a5e6e3..8becf375f96 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -213,4 +213,9 @@ describe("resolveEnableState", () => { reason: "workspace plugin (disabled by default)", }); }); + + it("keeps bundled provider plugins enabled when they are bundled-default providers", () => { + const state = resolveEnableState("google", "bundled", normalizePluginsConfig({})); + expect(state).toEqual({ enabled: true }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 493ad885f51..6cd04424fe2 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -29,6 +29,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "cloudflare-ai-gateway", "device-pair", "github-copilot", + "google", "huggingface", "kilocode", "kimi-coding", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a58d0a640a2..90f9b210398 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -28,7 +28,7 @@ import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import { setActivePluginRegistry } from "./runtime.js"; -import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; +import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import type { @@ -163,6 +163,25 @@ const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string return null; }; +function resolvePluginRuntimeModulePath(params: { modulePath?: string } = {}): string | null { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const moduleDir = path.dirname(modulePath); + const candidates = [ + path.join(moduleDir, "runtime", "index.ts"), + path.join(moduleDir, "runtime", "index.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +} + const cachedPluginSdkExportedSubpaths = new Map(); function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { @@ -747,11 +766,58 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi clearPluginInteractiveHandlers(); } + // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). + let jitiLoader: ReturnType | null = null; + const getJiti = () => { + if (jitiLoader) { + return jitiLoader; + } + const pluginSdkAlias = resolvePluginSdkAlias(); + const extensionApiAlias = resolveExtensionApiAlias(); + const aliasMap = { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap(), + }; + jitiLoader = createJiti(import.meta.url, { + interopDefault: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 + ? { + alias: aliasMap, + } + : {}), + }); + return jitiLoader; + }; + + let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = + null; + const resolveCreatePluginRuntime = (): (( + options?: CreatePluginRuntimeOptions, + ) => PluginRuntime) => { + if (createPluginRuntimeFactory) { + return createPluginRuntimeFactory; + } + const runtimeModulePath = resolvePluginRuntimeModulePath(); + if (!runtimeModulePath) { + throw new Error("Unable to resolve plugin runtime module"); + } + const runtimeModule = getJiti()(runtimeModulePath) as { + createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime; + }; + if (typeof runtimeModule.createPluginRuntime !== "function") { + throw new Error("Plugin runtime module missing createPluginRuntime export"); + } + createPluginRuntimeFactory = runtimeModule.createPluginRuntime; + return createPluginRuntimeFactory; + }; + // Lazily initialize the runtime so startup paths that discover/skip plugins do - // not eagerly load every channel runtime dependency. + // not eagerly load every channel/runtime dependency tree. let resolvedRuntime: PluginRuntime | null = null; const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); + resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); return resolvedRuntime; }; const runtime = new Proxy({} as PluginRuntime, { @@ -780,6 +846,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return Reflect.getPrototypeOf(resolveRuntime() as object); }, }); + const { registry, createApi } = createPluginRegistry({ logger, runtime, @@ -823,31 +890,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, }); - // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; - } - const pluginSdkAlias = resolvePluginSdkAlias(); - const extensionApiAlias = resolveExtensionApiAlias(); - const aliasMap = { - ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap(), - }; - jitiLoader = createJiti(import.meta.url, { - interopDefault: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }); - return jitiLoader; - }; - const manifestByRoot = new Map( manifestRegistry.plugins.map((record) => [record.rootDir, record]), ); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 84e5f13fd98..5156ea8a4a3 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -199,6 +199,28 @@ describe("loadPluginManifestRegistry", () => { ).toBe(true); }); + it("preserves provider auth env metadata from plugin manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "openai", + providers: ["openai", "openai-codex"], + providerAuthEnvVars: { + openai: ["OPENAI_API_KEY"], + }, + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "openai", + rootDir: dir, + origin: "bundled", + }); + + expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ + openai: ["OPENAI_API_KEY"], + }); + }); + it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => { const bundledDir = makeTempDir(); const globalDir = makeTempDir(); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 4f43cff8e2b..3a96d3036d5 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -41,6 +41,7 @@ export type PluginManifestRecord = { kind?: PluginKind; channels: string[]; providers: string[]; + providerAuthEnvVars?: Record; skills: string[]; settingsFiles?: string[]; hooks: string[]; @@ -152,6 +153,7 @@ function buildRecord(params: { kind: params.manifest.kind, channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], + providerAuthEnvVars: params.manifest.providerAuthEnvVars, skills: params.manifest.skills ?? [], settingsFiles: [], hooks: [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0cbdd9264f3..103ee620bf0 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -14,6 +14,7 @@ export type PluginManifest = { kind?: PluginKind; channels?: string[]; providers?: string[]; + providerAuthEnvVars?: Record; skills?: string[]; name?: string; description?: string; @@ -32,6 +33,25 @@ function normalizeStringList(value: unknown): string[] { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } +function normalizeStringListRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = {}; + for (const [key, rawValues] of Object.entries(value)) { + const providerId = typeof key === "string" ? key.trim() : ""; + if (!providerId) { + continue; + } + const values = normalizeStringList(rawValues); + if (values.length === 0) { + continue; + } + normalized[providerId] = values; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); @@ -93,6 +113,7 @@ export function loadPluginManifest( const version = typeof raw.version === "string" ? raw.version.trim() : undefined; const channels = normalizeStringList(raw.channels); const providers = normalizeStringList(raw.providers); + const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const skills = normalizeStringList(raw.skills); let uiHints: Record | undefined; @@ -108,6 +129,7 @@ export function loadPluginManifest( kind, channels, providers, + providerAuthEnvVars, skills, name, description, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 24bd47a915f..e38d6553080 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_: unknown) => undefined as string[] | undefined, +); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), })); import { @@ -41,6 +46,8 @@ describe("provider-runtime", () => { beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); it("matches providers by alias for runtime hook lookup", () => { @@ -56,9 +63,13 @@ describe("provider-runtime", () => { const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); expect(plugin?.id).toBe("openrouter"); - expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "Open Router", + }), + ); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e7ee62d8ebf..9e5104f7f86 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,6 +1,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; import type { ProviderAugmentModelCatalogContext, ProviderBuildMissingAuthMessageContext, @@ -60,9 +60,15 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolveProviderPluginsForHooks(params).find((plugin) => - matchesProviderId(plugin, params.provider), - ); + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }), + }).find((plugin) => matchesProviderId(plugin, params.provider)); } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 86ffb8e5ffc..a601336e5b9 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1,18 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; const loadOpenClawPluginsMock = vi.fn(); +const loadPluginManifestRegistryMock = vi.fn(); vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), })); +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistryMock(...args), +})); + describe("resolvePluginProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], }); + loadPluginManifestRegistryMock.mockReset(); + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [], + diagnostics: [], + }); }); it("forwards an explicit env to plugin loading", () => { @@ -86,4 +96,18 @@ describe("resolvePluginProviders", () => { expect(allow).toContain("google"); expect(allow).not.toContain("google-gemini-cli-auth"); }); + + it("maps provider ids to owning plugin ids via manifests", () => { + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [ + { id: "minimax", providers: ["minimax", "minimax-portal"] }, + { id: "openai", providers: ["openai", "openai-codex"] }, + ], + diagnostics: [], + }); + + expect(resolveOwningPluginIdsForProvider({ provider: "minimax-portal" })).toEqual(["minimax"]); + expect(resolveOwningPluginIdsForProvider({ provider: "openai-codex" })).toEqual(["openai"]); + expect(resolveOwningPluginIdsForProvider({ provider: "gemini-cli" })).toBeUndefined(); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index c1de0680359..e3215f2c6da 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,7 +1,9 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); @@ -86,6 +88,32 @@ function withBundledProviderVitestCompat(params: { }, }; } + +export function resolveOwningPluginIdsForProvider(params: { + provider: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] | undefined { + const normalizedProvider = normalizeProviderId(params.provider); + if (!normalizedProvider) { + return undefined; + } + + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const pluginIds = registry.plugins + .filter((plugin) => + plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider), + ) + .map((plugin) => plugin.id); + + return pluginIds.length > 0 ? pluginIds : undefined; +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 3b133642313..685858a9b6e 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -337,8 +337,6 @@ export type ProviderResolvedUsageAuth = { * This hook runs after `resolveUsageAuth` succeeds. Core still owns summary * fan-out, timeout wrapping, filtering, and formatting; the provider plugin * owns the provider-specific HTTP request + response normalization. - * - * Return `null`/`undefined` to fall back to legacy core fetchers. */ export type ProviderFetchUsageSnapshotContext = { config: OpenClawConfig; @@ -499,6 +497,12 @@ export type ProviderPlugin = { label: string; docsPath?: string; aliases?: string[]; + /** + * Provider-related env vars shown in onboarding/search/help surfaces. + * + * Keep entries in preferred display order. This can include direct auth env + * vars or setup inputs such as OAuth client id/secret vars. + */ envVars?: string[]; auth: ProviderAuthMethod[]; /** @@ -584,10 +588,9 @@ export type ProviderPlugin = { /** * Usage/billing auth resolution hook. * - * Called by provider-usage surfaces (`/usage`, status snapshots, reporting) - * before OpenClaw falls back to legacy core auth resolution. Use this when a - * provider's usage endpoint needs provider-owned token extraction, blob - * parsing, or alias handling. + * Called by provider-usage surfaces (`/usage`, status snapshots, reporting). + * Use this when a provider's usage endpoint needs provider-owned token + * extraction, blob parsing, or alias handling. */ resolveUsageAuth?: ( ctx: ProviderResolveUsageAuthContext, diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6e5b78f6643..6405d322e2f 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -10,10 +10,12 @@ describe("provider env vars", () => { expect(listKnownProviderAuthEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); - expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames()); - expect(listKnownSecretEnvVarNames()).not.toEqual( + expect(listKnownSecretEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); + expect(listKnownProviderAuthEnvVarNames()).toEqual( + expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]), + ); expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY"); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 88900893376..af89b57bf8d 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -1,50 +1,42 @@ -export const PROVIDER_ENV_VARS: Record = { - openai: ["OPENAI_API_KEY"], - anthropic: ["ANTHROPIC_API_KEY"], +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../plugins/bundled-provider-auth-env-vars.js"; + +const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], google: ["GEMINI_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - "minimax-cn": ["MINIMAX_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - together: ["TOGETHER_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - qianfan: ["QIANFAN_API_KEY"], - xai: ["XAI_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], +} as const; + +/** + * Provider auth env candidates used by generic auth resolution. + * + * Order matters: the first non-empty value wins for helpers such as + * `resolveEnvApiKey()`. Bundled providers source this from plugin manifest + * metadata so auth probes do not need to load plugin runtime. + */ +export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record = { + ...BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES, + ...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES, }; -const EXTRA_PROVIDER_AUTH_ENV_VARS = [ - "VOYAGE_API_KEY", - "GROQ_API_KEY", - "DEEPGRAM_API_KEY", - "CEREBRAS_API_KEY", - "NVIDIA_API_KEY", - "COPILOT_GITHUB_TOKEN", - "GH_TOKEN", - "GITHUB_TOKEN", - "ANTHROPIC_OAUTH_TOKEN", - "CHUTES_OAUTH_TOKEN", - "CHUTES_API_KEY", - "QWEN_OAUTH_TOKEN", - "QWEN_PORTAL_API_KEY", - "MINIMAX_OAUTH_TOKEN", - "OLLAMA_API_KEY", - "VLLM_API_KEY", -] as const; +/** + * Provider env vars used for onboarding/default secret refs and broad secret + * scrubbing. This can include non-model providers and may intentionally choose + * a different preferred first env var than auth resolution. + */ +export const PROVIDER_ENV_VARS: Record = { + ...PROVIDER_AUTH_ENV_VAR_CANDIDATES, + anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + google: ["GEMINI_API_KEY"], + "minimax-cn": ["MINIMAX_API_KEY"], + xai: ["XAI_API_KEY"], +}; + +const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const; const KNOWN_SECRET_ENV_VARS = [ ...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)), @@ -53,7 +45,11 @@ const KNOWN_SECRET_ENV_VARS = [ // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. const KNOWN_PROVIDER_AUTH_ENV_VARS = [ - ...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]), + ...new Set([ + ...Object.values(PROVIDER_AUTH_ENV_VAR_CANDIDATES).flatMap((keys) => keys), + ...KNOWN_SECRET_ENV_VARS, + ...EXTRA_PROVIDER_AUTH_ENV_VARS, + ]), ]; export function listKnownProviderAuthEnvVarNames(): string[] { From 3b26da4b820a246e1d0f06c93bb07d23f6eff781 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:26:38 -0700 Subject: [PATCH 079/331] CLI: route gateway status before program registration --- src/cli/program/routes.test.ts | 72 ++++++++++++++++++++++++++++++++++ src/cli/program/routes.ts | 48 +++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 0eb92333c0a..896dcb6757a 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,6 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -16,6 +17,10 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/gateway-status.js", () => ({ + gatewayStatusCommand: gatewayStatusCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +53,73 @@ describe("program routes", () => { expect(shouldLoad(["node", "openclaw", "health", "--json"])).toBe(false); }); + it("matches gateway status route without plugin preload", () => { + const route = expectRoute(["gateway", "status"]); + expect(route?.loadPlugins).toBeUndefined(); + }); + + it("returns false for gateway status route when option values are missing", async () => { + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--url"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--token"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--password"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--timeout"], + ); + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ); + }); + + it("passes parsed gateway status flags through", async () => { + const route = expectRoute(["gateway", "status"]); + await expect( + route?.run([ + "node", + "openclaw", + "--profile", + "work", + "gateway", + "status", + "--url", + "ws://127.0.0.1:18789", + "--token", + "abc", + "--password", + "def", + "--timeout", + "5000", + "--ssh", + "user@host", + "--ssh-identity", + "~/.ssh/id_test", + "--ssh-auto", + "--json", + ]), + ).resolves.toBe(true); + expect(gatewayStatusCommandMock).toHaveBeenCalledWith( + { + url: "ws://127.0.0.1:18789", + token: "abc", + password: "def", + timeout: "5000", + json: true, + ssh: "user@host", + sshIdentity: "~/.ssh/id_test", + sshAuto: true, + }, + expect.any(Object), + ); + }); + it("returns false when status timeout flag value is missing", async () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 52e0d8f8446..353c9b8f11d 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -53,6 +53,53 @@ const routeStatus: RouteSpec = { }, }; +const routeGatewayStatus: RouteSpec = { + match: (path) => path[0] === "gateway" && path[1] === "status", + run: async (argv) => { + const url = getFlagValue(argv, "--url"); + if (url === null) { + return false; + } + const token = getFlagValue(argv, "--token"); + if (token === null) { + return false; + } + const password = getFlagValue(argv, "--password"); + if (password === null) { + return false; + } + const timeout = getFlagValue(argv, "--timeout"); + if (timeout === null) { + return false; + } + const ssh = getFlagValue(argv, "--ssh"); + if (ssh === null) { + return false; + } + const sshIdentity = getFlagValue(argv, "--ssh-identity"); + if (sshIdentity === null) { + return false; + } + const sshAuto = hasFlag(argv, "--ssh-auto"); + const json = hasFlag(argv, "--json"); + const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); + await gatewayStatusCommand( + { + url: url ?? undefined, + token: token ?? undefined, + password: password ?? undefined, + timeout: timeout ?? undefined, + json, + ssh: ssh ?? undefined, + sshIdentity: sshIdentity ?? undefined, + sshAuto, + }, + defaultRuntime, + ); + return true; + }, +}; + const routeSessions: RouteSpec = { // Fast-path only bare `sessions`; subcommands (e.g. `sessions cleanup`) // must fall through to Commander so nested handlers run. @@ -251,6 +298,7 @@ const routeModelsStatus: RouteSpec = { const routes: RouteSpec[] = [ routeHealth, routeStatus, + routeGatewayStatus, routeSessions, routeAgentsList, routeMemoryStatus, From ae7f18e5033def8b4d49faca96cee7269223536b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:28:11 -0700 Subject: [PATCH 080/331] feat: add remote openshell sandbox mode --- CHANGELOG.md | 2 +- docs/gateway/configuration-reference.md | 8 +- docs/gateway/sandboxing.md | 52 +- extensions/openshell/src/backend.ts | 67 ++- extensions/openshell/src/config.test.ts | 13 + extensions/openshell/src/config.ts | 11 + extensions/openshell/src/fs-bridge.ts | 39 +- .../openshell/src/remote-fs-bridge.test.ts | 191 ++++++ extensions/openshell/src/remote-fs-bridge.ts | 550 ++++++++++++++++++ src/agents/apply-patch.test.ts | 42 ++ src/agents/apply-patch.ts | 6 +- src/agents/sandbox-media-paths.test.ts | 25 +- src/agents/sandbox-media-paths.ts | 15 +- src/agents/sandbox/fs-bridge.ts | 2 +- .../test-helpers/host-sandbox-fs-bridge.ts | 20 + 15 files changed, 1008 insertions(+), 35 deletions(-) create mode 100644 extensions/openshell/src/remote-fs-bridge.test.ts create mode 100644 extensions/openshell/src/remote-fs-bridge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98208595e0c..260d393c3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. -- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only. +- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. ### Fixes diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 78e58edc085..dbfc2b5dccb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1117,7 +1117,7 @@ See [Typing Indicators](/concepts/typing-indicators). ### `agents.defaults.sandbox` -Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide. +Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide. ```json5 { @@ -1125,6 +1125,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway defaults: { sandbox: { mode: "non-main", // off | non-main | all + backend: "docker", // docker | openshell scope: "agent", // session | agent | shared workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -1260,6 +1261,11 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived +When `backend: "openshell"` is selected, runtime-specific settings move to +`plugins.entries.openshell.config` (for example `mode: "mirror" | "remote"` and +`remoteWorkspaceDir`). Browser sandboxing and `sandbox.docker.binds` are +currently Docker-only. + Build images: ```bash diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index d62af2f4f7d..0e2219de14f 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -7,7 +7,7 @@ status: active # Sandboxing -OpenClaw can run **tools inside Docker containers** to reduce blast radius. +OpenClaw can run **tools inside sandbox backends** to reduce blast radius. This is **optional** and controlled by configuration (`agents.defaults.sandbox` or `agents.list[].sandbox`). If sandboxing is off, tools run on the host. The Gateway stays on the host; tool execution runs in an isolated sandbox @@ -54,6 +54,54 @@ Not sandboxed: - `"agent"`: one container per agent. - `"shared"`: one container shared by all sandboxed sessions. +## Backend + +`agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: + +- `"docker"` (default): local Docker-backed sandbox runtime. +- `"openshell"`: OpenShell-backed sandbox runtime provided by the bundled `openshell` plugin. + +OpenShell-specific config lives under `plugins.entries.openshell.config`. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "openshell", + scope: "session", + workspaceAccess: "rw", + }, + }, + }, + plugins: { + entries: { + openshell: { + enabled: true, + config: { + from: "openclaw", + mode: "remote", // mirror | remote + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + }, + }, + }, + }, +} +``` + +OpenShell modes: + +- `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec. +- `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back. + +Current OpenShell limitations: + +- sandbox browser is not supported yet +- `sandbox.docker.binds` is not supported on the OpenShell backend +- Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend + ## Workspace access `agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**: @@ -116,7 +164,7 @@ Security notes: ## Images + setup -Default image: `openclaw-sandbox:bookworm-slim` +Default Docker image: `openclaw-sandbox:bookworm-slim` Build it once: diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 48f730946d4..85c3d415904 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -24,6 +24,7 @@ import { import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; import { createOpenShellFsBridge } from "./fs-bridge.js"; import { replaceDirectoryContents } from "./mirror.js"; +import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; type CreateOpenShellSandboxBackendFactoryParams = { pluginConfig: ResolvedOpenShellPluginConfig; @@ -34,6 +35,7 @@ type PendingExec = { }; export type OpenShellSandboxBackend = SandboxBackendHandle & { + mode: "mirror" | "remote"; remoteWorkspaceDir: string; remoteAgentWorkspaceDir: string; runRemoteShellScript(params: SandboxBackendCommandParams): Promise; @@ -109,6 +111,7 @@ async function createOpenShellSandboxBackend(params: { runtimeLabel: sandboxName, workdir: params.pluginConfig.remoteWorkspaceDir, env: params.createParams.cfg.docker.env, + mode: params.pluginConfig.mode, configLabel: params.pluginConfig.from, configLabelKind: "Source", buildExecSpec: async ({ command, workdir, env, usePty }) => { @@ -125,10 +128,15 @@ async function createOpenShellSandboxBackend(params: { }, runShellCommand: async (command) => await impl.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => - createOpenShellFsBridge({ - sandbox, - backend: impl.asHandle(), - }), + params.pluginConfig.mode === "remote" + ? createOpenShellRemoteFsBridge({ + sandbox, + backend: impl.asHandle(), + }) + : createOpenShellFsBridge({ + sandbox, + backend: impl.asHandle(), + }), remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command), @@ -139,6 +147,7 @@ async function createOpenShellSandboxBackend(params: { class OpenShellSandboxBackendImpl { private ensurePromise: Promise | null = null; + private remoteSeedPending = false; constructor( private readonly params: { @@ -157,6 +166,7 @@ class OpenShellSandboxBackendImpl { runtimeLabel: this.params.execContext.sandboxName, workdir: this.params.remoteWorkspaceDir, env: this.params.createParams.cfg.docker.env, + mode: this.params.execContext.config.mode, configLabel: this.params.execContext.config.from, configLabelKind: "Source", remoteWorkspaceDir: this.params.remoteWorkspaceDir, @@ -175,10 +185,15 @@ class OpenShellSandboxBackendImpl { }, runShellCommand: async (command) => await self.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => - createOpenShellFsBridge({ - sandbox, - backend: self.asHandle(), - }), + this.params.execContext.config.mode === "remote" + ? createOpenShellRemoteFsBridge({ + sandbox, + backend: self.asHandle(), + }) + : createOpenShellFsBridge({ + sandbox, + backend: self.asHandle(), + }), runRemoteShellScript: async (command) => await self.runRemoteShellScript(command), syncLocalPathToRemote: async (localPath, remotePath) => await self.syncLocalPathToRemote(localPath, remotePath), @@ -192,7 +207,11 @@ class OpenShellSandboxBackendImpl { usePty: boolean; }): Promise<{ argv: string[]; token: PendingExec }> { await this.ensureSandboxExists(); - await this.syncWorkspaceToRemote(); + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceToRemote(); + } else { + await this.maybeSeedRemoteWorkspace(); + } const sshSession = await createOpenShellSshSession({ context: this.params.execContext, }); @@ -218,7 +237,9 @@ class OpenShellSandboxBackendImpl { async finalizeExec(token?: PendingExec): Promise { try { - await this.syncWorkspaceFromRemote(); + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceFromRemote(); + } } finally { if (token?.sshSession) { await disposeOpenShellSshSession(token.sshSession); @@ -230,6 +251,13 @@ class OpenShellSandboxBackendImpl { params: SandboxBackendCommandParams, ): Promise { await this.ensureSandboxExists(); + await this.maybeSeedRemoteWorkspace(); + return await this.runRemoteShellScriptInternal(params); + } + + private async runRemoteShellScriptInternal( + params: SandboxBackendCommandParams, + ): Promise { const session = await createOpenShellSshSession({ context: this.params.execContext, }); @@ -254,6 +282,7 @@ class OpenShellSandboxBackendImpl { async syncLocalPathToRemote(localPath: string, remotePath: string): Promise { await this.ensureSandboxExists(); + await this.maybeSeedRemoteWorkspace(); const stats = await fs.lstat(localPath).catch(() => null); if (!stats) { await this.runRemoteShellScript({ @@ -340,10 +369,11 @@ class OpenShellSandboxBackendImpl { if (createResult.code !== 0) { throw new Error(createResult.stderr.trim() || "openshell sandbox create failed"); } + this.remoteSeedPending = true; } private async syncWorkspaceToRemote(): Promise { - await this.runRemoteShellScript({ + await this.runRemoteShellScriptInternal({ script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', args: [this.params.remoteWorkspaceDir], }); @@ -357,7 +387,7 @@ class OpenShellSandboxBackendImpl { path.resolve(this.params.createParams.agentWorkspaceDir) !== path.resolve(this.params.createParams.workspaceDir) ) { - await this.runRemoteShellScript({ + await this.runRemoteShellScriptInternal({ script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', args: [this.params.remoteAgentWorkspaceDir], }); @@ -413,6 +443,19 @@ class OpenShellSandboxBackendImpl { throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); } } + + private async maybeSeedRemoteWorkspace(): Promise { + if (!this.remoteSeedPending) { + return; + } + this.remoteSeedPending = false; + try { + await this.syncWorkspaceToRemote(); + } catch (error) { + this.remoteSeedPending = true; + throw error; + } + } } function resolveOpenShellPluginConfigFromConfig( diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts index 66734ca43e0..f46fec1cd46 100644 --- a/extensions/openshell/src/config.test.ts +++ b/extensions/openshell/src/config.test.ts @@ -4,6 +4,7 @@ import { resolveOpenShellPluginConfig } from "./config.js"; describe("openshell plugin config", () => { it("applies defaults", () => { expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + mode: "mirror", command: "openshell", gateway: undefined, gatewayEndpoint: undefined, @@ -18,6 +19,10 @@ describe("openshell plugin config", () => { }); }); + it("accepts remote mode", () => { + expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote"); + }); + it("rejects relative remote paths", () => { expect(() => resolveOpenShellPluginConfig({ @@ -25,4 +30,12 @@ describe("openshell plugin config", () => { }), ).toThrow("OpenShell remote path must be absolute"); }); + + it("rejects unknown mode", () => { + expect(() => + resolveOpenShellPluginConfig({ + mode: "bogus", + }), + ).toThrow("mode must be one of mirror, remote"); + }); }); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts index 53e5f06584b..58b40180cd9 100644 --- a/extensions/openshell/src/config.ts +++ b/extensions/openshell/src/config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; export type OpenShellPluginConfig = { + mode?: string; command?: string; gateway?: string; gatewayEndpoint?: string; @@ -16,6 +17,7 @@ export type OpenShellPluginConfig = { }; export type ResolvedOpenShellPluginConfig = { + mode: "mirror" | "remote"; command: string; gateway?: string; gatewayEndpoint?: string; @@ -30,6 +32,7 @@ export type ResolvedOpenShellPluginConfig = { }; const DEFAULT_COMMAND = "openshell"; +const DEFAULT_MODE = "mirror"; const DEFAULT_SOURCE = "openclaw"; const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; @@ -99,6 +102,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema }; } const allowedKeys = new Set([ + "mode", "command", "gateway", "gatewayEndpoint", @@ -156,6 +160,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema return { success: true, data: { + mode: trimString(value.mode), command: trimString(value.command), gateway: trimString(value.gateway), gatewayEndpoint: trimString(value.gatewayEndpoint), @@ -178,6 +183,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema additionalProperties: false, properties: { command: { type: "string" }, + mode: { type: "string", enum: ["mirror", "remote"] }, gateway: { type: "string" }, gatewayEndpoint: { type: "string" }, from: { type: "string" }, @@ -203,7 +209,12 @@ export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellP } const raw = parsed.data ?? {}; const cfg = (raw ?? {}) as OpenShellPluginConfig; + const mode = cfg.mode ?? DEFAULT_MODE; + if (mode !== "mirror" && mode !== "remote") { + throw new Error(`Invalid openshell plugin config: mode must be one of mirror, remote`); + } return { + mode, command: cfg.command ?? DEFAULT_COMMAND, gateway: cfg.gateway, gatewayEndpoint: cfg.gatewayEndpoint, diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index b9ab9b01549..00257e81be4 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -43,13 +43,14 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: false, allowFinalSymlinkForUnlink: false, }); - return await fsPromises.readFile(target.hostPath); + return await fsPromises.readFile(hostPath); } async writeFile(params: { @@ -61,6 +62,7 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "write files"); await assertLocalPathSafety({ target, @@ -71,21 +73,22 @@ class OpenShellFsBridge implements SandboxFsBridge { const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); - const parentDir = path.dirname(target.hostPath); + const parentDir = path.dirname(hostPath); if (params.mkdir !== false) { await fsPromises.mkdir(parentDir, { recursive: true }); } const tempPath = path.join( parentDir, - `.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`, + `.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`, ); await fsPromises.writeFile(tempPath, buffer); - await fsPromises.rename(tempPath, target.hostPath); - await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath); + await fsPromises.rename(tempPath, hostPath); + await this.backend.syncLocalPathToRemote(hostPath, target.containerPath); } async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "create directories"); await assertLocalPathSafety({ target, @@ -93,7 +96,7 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); - await fsPromises.mkdir(target.hostPath, { recursive: true }); + await fsPromises.mkdir(hostPath, { recursive: true }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$1"', args: [target.containerPath], @@ -109,6 +112,7 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "remove files"); await assertLocalPathSafety({ target, @@ -116,7 +120,7 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: params.force !== false, allowFinalSymlinkForUnlink: true, }); - await fsPromises.rm(target.hostPath, { + await fsPromises.rm(hostPath, { recursive: params.recursive ?? false, force: params.force !== false, }); @@ -138,6 +142,8 @@ class OpenShellFsBridge implements SandboxFsBridge { }): Promise { const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + const fromHostPath = this.requireHostPath(from); + const toHostPath = this.requireHostPath(to); this.ensureWritable(from, "rename files"); this.ensureWritable(to, "rename files"); await assertLocalPathSafety({ @@ -152,8 +158,8 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); - await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true }); - await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath }); + await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true }); + await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', args: [from.containerPath, to.containerPath], @@ -167,7 +173,8 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); - const stats = await fsPromises.lstat(target.hostPath).catch(() => null); + const hostPath = this.requireHostPath(target); + const stats = await fsPromises.lstat(hostPath).catch(() => null); if (!stats) { return null; } @@ -190,6 +197,15 @@ class OpenShellFsBridge implements SandboxFsBridge { } } + private requireHostPath(target: ResolvedMountPath): string { + if (!target.hostPath) { + throw new Error( + `OpenShell mirror bridge requires a local host path: ${target.containerPath}`, + ); + } + return target.hostPath; + } + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { const workspaceRoot = path.resolve(this.sandbox.workspaceDir); const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); @@ -282,6 +298,9 @@ async function assertLocalPathSafety(params: { allowMissingLeaf: boolean; allowFinalSymlinkForUnlink: boolean; }): Promise { + if (!params.target.hostPath) { + throw new Error(`Missing local host path for ${params.target.containerPath}`); + } const canonicalRoot = await fsPromises .realpath(params.root) .catch(() => path.resolve(params.root)); diff --git a/extensions/openshell/src/remote-fs-bridge.test.ts b/extensions/openshell/src/remote-fs-bridge.test.ts new file mode 100644 index 00000000000..5a245e1d8fb --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.test.ts @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function translateRemotePath(value: string, roots: { workspace: string; agent: string }) { + if (value === "/sandbox" || value.startsWith("/sandbox/")) { + return path.join(roots.workspace, value.slice("/sandbox".length)); + } + if (value === "/agent" || value.startsWith("/agent/")) { + return path.join(roots.agent, value.slice("/agent".length)); + } + return value; +} + +async function runLocalShell(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + roots: { workspace: string; agent: string }; +}) { + const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots)); + const script = normalizeScriptForLocalShell(params.script); + const result = await new Promise<{ stdout: Buffer; stderr: Buffer; code: number }>( + (resolve, reject) => { + const child = spawn("/bin/sh", ["-c", script, "openshell-test", ...translatedArgs], { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const result = { + stdout: Buffer.concat(stdoutChunks), + stderr: Buffer.concat(stderrChunks), + code: code ?? 0, + }; + if (result.code !== 0 && !params.allowFailure) { + reject( + new Error( + result.stderr.toString("utf8").trim() || `script exited with code ${result.code}`, + ), + ); + return; + } + resolve(result); + }); + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }, + ); + return { + ...result, + stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"), + }; +} + +function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + mode: "remote", + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn( + async (params) => + await runLocalShell({ + ...params, + roots, + }), + ), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) { + return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent"); +} + +function normalizeScriptForLocalShell(script: string) { + return script + .replace( + 'stats=$(stat -c "%F|%h" -- "$1")', + `stats=$(python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_nlink}") +PY +)`, + ) + .replace( + 'stat -c "%F|%s|%Y" -- "$1"', + `python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_size}|{int(st.st_mtime)}") +PY`, + ); +} + +describe("openshell remote fs bridge", () => { + it("writes, reads, renames, and removes files without local host paths", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-"); + const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-"); + const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-"); + const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir); + const remoteAgentRealDir = await fs.realpath(remoteAgentDir); + const backend = createBackendMock({ + workspace: remoteWorkspaceRealDir, + agent: remoteAgentRealDir, + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellRemoteFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe( + "hello", + ); + expect(await fs.readdir(workspaceDir)).toEqual([]); + + const resolved = bridge.resolvePath({ filePath: "nested/file.txt" }); + expect(resolved.hostPath).toBeUndefined(); + expect(resolved.containerPath).toBe("/sandbox/nested/file.txt"); + expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello")); + expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual( + expect.objectContaining({ + type: "file", + size: 5, + }), + ); + + await bridge.rename({ + from: "nested/file.txt", + to: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"), + ).rejects.toBeDefined(); + expect( + await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).toBe("hello"); + + await bridge.remove({ + filePath: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).rejects.toBeDefined(); + }); +}); diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts new file mode 100644 index 00000000000..3560fa78f28 --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -0,0 +1,550 @@ +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellRemoteFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellRemoteFsBridge(params.sandbox, params.backend); +} + +class OpenShellRemoteFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); + const mounts: MountInfo[] = [ + { + containerRoot: workspaceContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: agentContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0]!, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonical, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonicalParent, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.backend.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 1f305379b5d..5182dfdf0af 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -361,4 +361,46 @@ describe("applyPatch", () => { } }); }); + + it("uses container paths when the sandbox bridge has no local host path", async () => { + const files = new Map([["/sandbox/source.txt", "before\n"]]); + const bridge = { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + readFile: vi.fn(async ({ filePath }: { filePath: string }) => + Buffer.from(files.get(filePath) ?? "", "utf8"), + ), + writeFile: vi.fn(async ({ filePath, data }: { filePath: string; data: Buffer | string }) => { + files.set(filePath, Buffer.isBuffer(data) ? data.toString("utf8") : data); + }), + remove: vi.fn(async ({ filePath }: { filePath: string }) => { + files.delete(filePath); + }), + mkdirp: vi.fn(async () => {}), + }; + + const patch = `*** Begin Patch +*** Update File: source.txt +@@ +-before ++after +*** End Patch`; + + const result = await applyPatch(patch, { + cwd: "/local/workspace", + sandbox: { + root: "/local/workspace", + bridge: bridge as never, + }, + }); + + expect(files.get("/sandbox/source.txt")).toBe("after\n"); + expect(result.summary.modified).toEqual(["source.txt"]); + expect(bridge.readFile).toHaveBeenCalledWith({ + filePath: "/sandbox/source.txt", + cwd: "/local/workspace", + }); + }); }); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index d7a5dc1e0ff..0fc612923c1 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -313,7 +313,7 @@ async function resolvePatchPath( filePath, cwd: options.cwd, }); - if (options.workspaceOnly !== false) { + if (options.workspaceOnly !== false && resolved.hostPath) { await assertSandboxPath({ filePath: resolved.hostPath, cwd: options.cwd, @@ -323,8 +323,8 @@ async function resolvePatchPath( }); } return { - resolved: resolved.hostPath, - display: resolved.relativePath || resolved.hostPath, + resolved: resolved.hostPath ?? resolved.containerPath, + display: resolved.relativePath || resolved.containerPath, }; } diff --git a/src/agents/sandbox-media-paths.test.ts b/src/agents/sandbox-media-paths.test.ts index 4179c2a68ef..0007e943fdd 100644 --- a/src/agents/sandbox-media-paths.test.ts +++ b/src/agents/sandbox-media-paths.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { createSandboxBridgeReadFile } from "./sandbox-media-paths.js"; +import { + createSandboxBridgeReadFile, + resolveSandboxedBridgeMediaPath, +} from "./sandbox-media-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; describe("createSandboxBridgeReadFile", () => { @@ -19,4 +22,24 @@ describe("createSandboxBridgeReadFile", () => { cwd: "/tmp/sandbox-root", }); }); + + it("falls back to container paths when the bridge has no host path", async () => { + const stat = vi.fn(async () => ({ type: "file", size: 1, mtimeMs: 1 })); + const resolved = await resolveSandboxedBridgeMediaPath({ + sandbox: { + root: "/tmp/sandbox-root", + bridge: { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + stat, + } as unknown as SandboxFsBridge, + }, + mediaPath: "image.png", + }); + + expect(resolved).toEqual({ resolved: "/sandbox/image.png" }); + expect(stat).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/sandbox-media-paths.ts b/src/agents/sandbox-media-paths.ts index 3c6b2614c94..1c46f392482 100644 --- a/src/agents/sandbox-media-paths.ts +++ b/src/agents/sandbox-media-paths.ts @@ -44,8 +44,10 @@ export async function resolveSandboxedBridgeMediaPath(params: { }); try { const resolved = resolveDirect(); - await enforceWorkspaceBoundary(resolved.hostPath); - return { resolved: resolved.hostPath }; + if (resolved.hostPath) { + await enforceWorkspaceBoundary(resolved.hostPath); + } + return { resolved: resolved.hostPath ?? resolved.containerPath }; } catch (err) { const fallbackDir = params.inboundFallbackDir?.trim(); if (!fallbackDir) { @@ -67,7 +69,12 @@ export async function resolveSandboxedBridgeMediaPath(params: { filePath: fallbackPath, cwd: params.sandbox.root, }); - await enforceWorkspaceBoundary(resolvedFallback.hostPath); - return { resolved: resolvedFallback.hostPath, rewrittenFrom: filePath }; + if (resolvedFallback.hostPath) { + await enforceWorkspaceBoundary(resolvedFallback.hostPath); + } + return { + resolved: resolvedFallback.hostPath ?? resolvedFallback.containerPath, + rewrittenFrom: filePath, + }; } } diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 16c307e053c..7941b2b6828 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -24,7 +24,7 @@ type RunCommandOptions = { }; export type SandboxResolvedPath = { - hostPath: string; + hostPath?: string; relativePath: string; containerPath: string; }; diff --git a/src/agents/test-helpers/host-sandbox-fs-bridge.ts b/src/agents/test-helpers/host-sandbox-fs-bridge.ts index 93bb34969a8..fc466f0ea67 100644 --- a/src/agents/test-helpers/host-sandbox-fs-bridge.ts +++ b/src/agents/test-helpers/host-sandbox-fs-bridge.ts @@ -10,10 +10,16 @@ export function createSandboxFsBridgeFromResolver( resolvePath: ({ filePath, cwd }) => resolvePath(filePath, cwd), readFile: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } return fs.readFile(target.hostPath); }, writeFile: async ({ filePath, cwd, data, mkdir = true }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } if (mkdir) { await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); } @@ -22,10 +28,16 @@ export function createSandboxFsBridgeFromResolver( }, mkdirp: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.mkdir(target.hostPath, { recursive: true }); }, remove: async ({ filePath, cwd, recursive, force }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.rm(target.hostPath, { recursive: recursive ?? false, force: force ?? false, @@ -34,12 +46,20 @@ export function createSandboxFsBridgeFromResolver( rename: async ({ from, to, cwd }) => { const source = resolvePath(from, cwd); const target = resolvePath(to, cwd); + if (!source.hostPath || !target.hostPath) { + throw new Error( + `Expected hostPath for rename: ${source.containerPath} -> ${target.containerPath}`, + ); + } await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); await fs.rename(source.hostPath, target.hostPath); }, stat: async ({ filePath, cwd }) => { try { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } const stats = await fs.stat(target.hostPath); return { type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", From be8fef3840b03f9511f7153ad8bc93773477de45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:35:56 -0700 Subject: [PATCH 081/331] docs: expand openshell sandbox docs --- docs/cli/sandbox.md | 56 ++++++++++++------ docs/gateway/configuration-reference.md | 46 +++++++++++++-- docs/gateway/sandboxing.md | 76 ++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e8e4614a9ff..5ebac698175 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -1,17 +1,22 @@ --- title: Sandbox CLI -summary: "Manage sandbox containers and inspect effective sandbox policy" -read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior." +summary: "Manage sandbox runtimes and inspect effective sandbox policy" +read_when: "You are managing sandbox runtimes or debugging sandbox/tool-policy behavior." status: active --- # Sandbox CLI -Manage Docker-based sandbox containers for isolated agent execution. +Manage sandbox runtimes for isolated agent execution. ## Overview -OpenClaw can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes. +OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` commands help you inspect and recreate those runtimes after updates or configuration changes. + +Today that usually means: + +- Docker sandbox containers +- OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` ## Commands @@ -28,7 +33,7 @@ openclaw sandbox explain --json ### `openclaw sandbox list` -List all sandbox containers with their status and configuration. +List all sandbox runtimes with their status and configuration. ```bash openclaw sandbox list @@ -38,15 +43,16 @@ openclaw sandbox list --json # JSON output **Output includes:** -- Container name and status (running/stopped) -- Docker image and whether it matches config +- Runtime name and status +- Backend (`docker`, `openshell`, etc.) +- Config label and whether it matches current config - Age (time since creation) - Idle time (time since last use) - Associated session/agent ### `openclaw sandbox recreate` -Remove sandbox containers to force recreation with updated images/config. +Remove sandbox runtimes to force recreation with updated config. ```bash openclaw sandbox recreate --all # Recreate all containers @@ -64,11 +70,11 @@ openclaw sandbox recreate --all --force # Skip confirmation - `--browser`: Only recreate browser containers - `--force`: Skip confirmation prompt -**Important:** Containers are automatically recreated when the agent is next used. +**Important:** Runtimes are automatically recreated when the agent is next used. ## Use Cases -### After updating Docker images +### After updating a Docker image ```bash # Pull new image @@ -91,6 +97,21 @@ openclaw sandbox recreate --all openclaw sandbox recreate --all ``` +### After changing OpenShell source, policy, or mode + +```bash +# Edit config: +# - agents.defaults.sandbox.backend +# - plugins.entries.openshell.config.from +# - plugins.entries.openshell.config.mode +# - plugins.entries.openshell.config.policy + +openclaw sandbox recreate --all +``` + +For OpenShell `remote` mode, recreate deletes the canonical remote workspace +for that scope. The next run seeds it again from the local workspace. + ### After changing setupCommand ```bash @@ -108,16 +129,16 @@ openclaw sandbox recreate --agent alfred ## Why is this needed? -**Problem:** When you update sandbox Docker images or configuration: +**Problem:** When you update sandbox configuration: -- Existing containers continue running with old settings -- Containers are only pruned after 24h of inactivity -- Regularly-used agents keep old containers running indefinitely +- Existing runtimes continue running with old settings +- Runtimes are only pruned after 24h of inactivity +- Regularly-used agents keep old runtimes alive indefinitely -**Solution:** Use `openclaw sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed. +**Solution:** Use `openclaw sandbox recreate` to force removal of old runtimes. They'll be recreated automatically with current settings when next needed. -Tip: prefer `openclaw sandbox recreate` over manual `docker rm`. It uses the -Gateway’s container naming and avoids mismatches when scope/session keys change. +Tip: prefer `openclaw sandbox recreate` over manual backend-specific cleanup. +It uses the Gateway’s runtime registry and avoids mismatches when scope/session keys change. ## Configuration @@ -129,6 +150,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all + "backend": "docker", // docker, openshell "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index dbfc2b5dccb..951f99f1165 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1200,6 +1200,14 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing +**Backend:** + +- `docker`: local Docker runtime (default) +- `openshell`: OpenShell runtime + +When `backend: "openshell"` is selected, runtime-specific settings move to +`plugins.entries.openshell.config`. + **Workspace access:** - `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes` @@ -1212,6 +1220,39 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing - `agent`: one container + workspace per agent (default) - `shared`: shared container and workspace (no cross-session isolation) +**OpenShell plugin config:** + +```json5 +{ + plugins: { + entries: { + openshell: { + enabled: true, + config: { + mode: "mirror", // mirror | remote + from: "openclaw", + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + gateway: "lab", // optional + gatewayEndpoint: "https://lab.example", // optional + policy: "strict", // optional OpenShell policy id + providers: ["openai"], // optional + autoProviders: true, + timeoutSeconds: 120, + }, + }, + }, + }, +} +``` + +**OpenShell mode:** + +- `mirror`: seed remote from local before exec, sync back after exec; local workspace stays canonical +- `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical + +In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step. + **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. **Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access. @@ -1261,10 +1302,7 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived -When `backend: "openshell"` is selected, runtime-specific settings move to -`plugins.entries.openshell.config` (for example `mode: "mirror" | "remote"` and -`remoteWorkspaceDir`). Browser sandboxing and `sandbox.docker.binds` are -currently Docker-only. +Browser sandboxing and `sandbox.docker.binds` are currently Docker-only. Build images: diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 0e2219de14f..db40b802832 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -59,7 +59,7 @@ Not sandboxed: `agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: - `"docker"` (default): local Docker-backed sandbox runtime. -- `"openshell"`: OpenShell-backed sandbox runtime provided by the bundled `openshell` plugin. +- `"openshell"`: OpenShell-backed sandbox runtime. OpenShell-specific config lives under `plugins.entries.openshell.config`. @@ -102,6 +102,72 @@ Current OpenShell limitations: - `sandbox.docker.binds` is not supported on the OpenShell backend - Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend +## OpenShell workspace modes + +OpenShell has two workspace models. This is the part that matters most in practice. + +### `mirror` + +Use `plugins.entries.openshell.config.mode: "mirror"` when you want the **local workspace to stay canonical**. + +Behavior: + +- Before `exec`, OpenClaw syncs the local workspace into the OpenShell sandbox. +- After `exec`, OpenClaw syncs the remote workspace back to the local workspace. +- File tools still operate through the sandbox bridge, but the local workspace remains the source of truth between turns. + +Use this when: + +- you edit files locally outside OpenClaw and want those changes to show up in the sandbox automatically +- you want the OpenShell sandbox to behave as much like the Docker backend as possible +- you want the host workspace to reflect sandbox writes after each exec turn + +Tradeoff: + +- extra sync cost before and after exec + +### `remote` + +Use `plugins.entries.openshell.config.mode: "remote"` when you want the **OpenShell workspace to become canonical**. + +Behavior: + +- When the sandbox is first created, OpenClaw seeds the remote workspace from the local workspace once. +- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace. +- OpenClaw does **not** sync remote changes back into the local workspace after exec. +- Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path. + +Important consequences: + +- If you edit files on the host outside OpenClaw after the seed step, the remote sandbox will **not** see those changes automatically. +- If the sandbox is recreated, the remote workspace is seeded from the local workspace again. +- With `scope: "agent"` or `scope: "shared"`, that remote workspace is shared at that same scope. + +Use this when: + +- the sandbox should live primarily on the remote OpenShell side +- you want lower per-turn sync overhead +- you do not want host-local edits to silently overwrite remote sandbox state + +Choose `mirror` if you think of the sandbox as a temporary execution environment. +Choose `remote` if you think of the sandbox as the real workspace. + +## OpenShell lifecycle + +OpenShell sandboxes are still managed through the normal sandbox lifecycle: + +- `openclaw sandbox list` shows OpenShell runtimes as well as Docker runtimes +- `openclaw sandbox recreate` deletes the current runtime and lets OpenClaw recreate it on next use +- prune logic is backend-aware too + +For `remote` mode, recreate is especially important: + +- recreate deletes the canonical remote workspace for that scope +- the next use seeds a fresh remote workspace from the local workspace + +For `mirror` mode, recreate mainly resets the remote execution environment +because the local workspace remains canonical anyway. + ## Workspace access `agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**: @@ -110,6 +176,12 @@ Current OpenShell limitations: - `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`/`apply_patch`). - `"rw"`: mounts the agent workspace read/write at `/workspace`. +With the OpenShell backend: + +- `mirror` mode still uses the local workspace as the canonical source between exec turns +- `remote` mode uses the remote OpenShell workspace as the canonical source after the initial seed +- `workspaceAccess: "ro"` and `"none"` still restrict write behavior the same way + Inbound media is copied into the active sandbox workspace (`media/inbound/*`). Skills note: the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so @@ -193,7 +265,7 @@ Sandboxed browser image: scripts/sandbox-browser-setup.sh ``` -By default, sandbox containers run with **no network**. +By default, Docker sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. The bundled sandbox browser image also applies conservative Chromium startup defaults From aa28d1c71138b2e2d85511e40fae5983e3ae621e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 03:38:51 +0000 Subject: [PATCH 082/331] feat: add firecrawl onboarding search plugin --- CHANGELOG.md | 1 + docs/refactor/firecrawl-extension.md | 260 ++++++++++ docs/tools/firecrawl.md | 86 +++- docs/tools/index.md | 2 +- docs/tools/web.md | 56 ++- extensions/firecrawl/index.test.ts | 100 ++++ extensions/firecrawl/index.ts | 20 + extensions/firecrawl/openclaw.plugin.json | 8 + extensions/firecrawl/package.json | 12 + extensions/firecrawl/src/config.ts | 159 +++++++ extensions/firecrawl/src/firecrawl-client.ts | 446 ++++++++++++++++++ .../firecrawl/src/firecrawl-scrape-tool.ts | 89 ++++ .../src/firecrawl-search-provider.ts | 63 +++ .../firecrawl/src/firecrawl-search-tool.ts | 76 +++ src/agents/tools/web-fetch-utils.ts | 37 +- src/agents/tools/web-fetch.ts | 122 +++-- src/agents/tools/web-tools.fetch.test.ts | 59 ++- src/commands/onboard-search.test.ts | 19 +- src/commands/onboard-search.ts | 15 +- src/config/config.web-search-provider.test.ts | 26 + src/config/schema.help.ts | 6 +- src/config/schema.labels.ts | 2 + src/config/types.tools.ts | 11 +- src/config/zod-schema.agent-runtime.ts | 8 + src/plugins/web-search-providers.test.ts | 1 + src/plugins/web-search-providers.ts | 1 + 26 files changed, 1593 insertions(+), 92 deletions(-) create mode 100644 docs/refactor/firecrawl-extension.md create mode 100644 extensions/firecrawl/index.test.ts create mode 100644 extensions/firecrawl/index.ts create mode 100644 extensions/firecrawl/openclaw.plugin.json create mode 100644 extensions/firecrawl/package.json create mode 100644 extensions/firecrawl/src/config.ts create mode 100644 extensions/firecrawl/src/firecrawl-client.ts create mode 100644 extensions/firecrawl/src/firecrawl-scrape-tool.ts create mode 100644 extensions/firecrawl/src/firecrawl-search-provider.ts create mode 100644 extensions/firecrawl/src/firecrawl-search-tool.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 260d393c3cb..07937512400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. - Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. - Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) +- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md new file mode 100644 index 00000000000..e25e010e7b1 --- /dev/null +++ b/docs/refactor/firecrawl-extension.md @@ -0,0 +1,260 @@ +--- +summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults" +read_when: + - Designing Firecrawl integration work + - Evaluating web_search/web_fetch plugin seams + - Deciding whether Firecrawl belongs in core or as an extension +title: "Firecrawl Extension Design" +--- + +# Firecrawl Extension Design + +## Goal + +Ship Firecrawl as an **opt-in extension** that adds: + +- explicit Firecrawl tools for agents, +- optional Firecrawl-backed `web_search` integration, +- self-hosted support, +- stronger security defaults than the current core fallback path, + +without pushing Firecrawl into the default setup/onboarding path. + +## Why this shape + +Recent Firecrawl issues/PRs cluster into three buckets: + +1. **Release/schema drift** + - Several releases rejected `tools.web.fetch.firecrawl` even though docs and runtime code supported it. +2. **Security hardening** + - Current `fetchFirecrawlContent()` still posts to the Firecrawl endpoint with raw `fetch()`, while the main web-fetch path uses the SSRF guard. +3. **Product pressure** + - Users want Firecrawl-native search/scrape flows, especially for self-hosted/private setups. + - Maintainers explicitly rejected wiring Firecrawl deeply into core defaults, setup flow, and browser behavior. + +That combination argues for an extension, not more Firecrawl-specific logic in the default core path. + +## Design principles + +- **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening. +- **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again. +- **Useful on day one**: works even if core `web_search` / `web_fetch` seams stay unchanged. +- **Security-first**: endpoint fetches use the same guarded networking posture as other web tools. +- **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions. + +## Proposed extension + +Plugin id: `firecrawl` + +### MVP capabilities + +Register explicit tools: + +- `firecrawl_search` +- `firecrawl_scrape` + +Optional later: + +- `firecrawl_crawl` +- `firecrawl_map` + +Do **not** add Firecrawl browser automation in the first version. That was the part of PR #32543 that pulled Firecrawl too far into core behavior and raised the most maintainership concern. + +## Config shape + +Use plugin-scoped config: + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + config: { + apiKey: "FIRECRAWL_API_KEY", + baseUrl: "https://api.firecrawl.dev", + timeoutSeconds: 60, + maxAgeMs: 172800000, + proxy: "auto", + storeInCache: true, + onlyMainContent: true, + search: { + enabled: true, + defaultLimit: 5, + sources: ["web"], + categories: [], + scrapeResults: false, + }, + scrape: { + formats: ["markdown"], + fallbackForWebFetchLikeUse: false, + }, + }, + }, + }, + }, +} +``` + +### Credential resolution + +Precedence: + +1. `plugins.entries.firecrawl.config.apiKey` +2. `FIRECRAWL_API_KEY` + +Base URL precedence: + +1. `plugins.entries.firecrawl.config.baseUrl` +2. `FIRECRAWL_BASE_URL` +3. `https://api.firecrawl.dev` + +### Compatibility bridge + +For the first release, the extension may also **read** existing core config at `tools.web.fetch.firecrawl.*` as a fallback source so existing users do not need to migrate immediately. + +Write path stays plugin-local. Do not keep expanding core Firecrawl config surfaces. + +## Tool design + +### `firecrawl_search` + +Inputs: + +- `query` +- `limit` +- `sources` +- `categories` +- `scrapeResults` +- `timeoutSeconds` + +Behavior: + +- Calls Firecrawl `v2/search` +- Returns normalized OpenClaw-friendly result objects: + - `title` + - `url` + - `snippet` + - `source` + - optional `content` +- Wraps result content as untrusted external content +- Cache key includes query + relevant provider params + +Why explicit tool first: + +- Works today without changing `tools.web.search.provider` +- Avoids current schema/loader constraints +- Gives users Firecrawl value immediately + +### `firecrawl_scrape` + +Inputs: + +- `url` +- `formats` +- `onlyMainContent` +- `maxAgeMs` +- `proxy` +- `storeInCache` +- `timeoutSeconds` + +Behavior: + +- Calls Firecrawl `v2/scrape` +- Returns markdown/text plus metadata: + - `title` + - `finalUrl` + - `status` + - `warning` +- Wraps extracted content the same way `web_fetch` does +- Shares cache semantics with web tool expectations where practical + +Why explicit scrape tool: + +- Sidesteps the unresolved `Readability -> Firecrawl -> basic HTML cleanup` ordering bug in core `web_fetch` +- Gives users a deterministic “always use Firecrawl” path for JS-heavy/bot-protected sites + +## What the extension should not do + +- No auto-adding `browser`, `web_search`, or `web_fetch` to `tools.alsoAllow` +- No default onboarding step in `openclaw setup` +- No Firecrawl-specific browser session lifecycle in core +- No change to built-in `web_fetch` fallback semantics in the extension MVP + +## Phase plan + +### Phase 1: extension-only, no core schema changes + +Implement: + +- `extensions/firecrawl/` +- plugin config schema +- `firecrawl_search` +- `firecrawl_scrape` +- tests for config resolution, endpoint selection, caching, error handling, and SSRF guard usage + +This phase is enough to ship real user value. + +### Phase 2: optional `web_search` provider integration + +Support `tools.web.search.provider = "firecrawl"` only after fixing two core constraints: + +1. `src/plugins/web-search-providers.ts` must load configured/installed web-search-provider plugins instead of a hardcoded bundled list. +2. `src/config/types.tools.ts` and `src/config/zod-schema.agent-runtime.ts` must stop hardcoding the provider enum in a way that blocks plugin-registered ids. + +Recommended shape: + +- keep built-in providers documented, +- allow any registered plugin provider id at runtime, +- validate provider-specific config via the provider plugin or a generic provider bag. + +### Phase 3: optional `web_fetch` provider seam + +Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`. + +Needed core addition: + +- `registerWebFetchProvider` or equivalent fetch-backend seam + +Without that seam, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. + +## Security requirements + +The extension must treat Firecrawl as a **trusted operator-configured endpoint**, but still harden transport: + +- Use SSRF-guarded fetch for the Firecrawl endpoint call, not raw `fetch()` +- Preserve self-hosted/private-network compatibility using the same trusted-web-tools endpoint policy used elsewhere +- Never log the API key +- Keep endpoint/base URL resolution explicit and predictable +- Treat Firecrawl-returned content as untrusted external content + +This mirrors the intent behind the SSRF hardening PRs without assuming Firecrawl is a hostile multi-tenant surface. + +## Why not a skill + +The repo already closed a Firecrawl skill PR in favor of ClawHub distribution. That is fine for optional user-installed prompt workflows, but it does not solve: + +- deterministic tool availability, +- provider-grade config/credential handling, +- self-hosted endpoint support, +- caching, +- stable typed outputs, +- security review on network behavior. + +This belongs as an extension, not a prompt-only skill. + +## Success criteria + +- Users can install/enable one extension and get reliable Firecrawl search/scrape without touching core defaults. +- Self-hosted Firecrawl works with config/env fallback. +- Extension endpoint fetches use guarded networking. +- No new Firecrawl-specific core onboarding/default behavior. +- Core can later adopt plugin-native `web_search` / `web_fetch` seams without redesigning the extension. + +## Recommended implementation order + +1. Build `firecrawl_scrape` +2. Build `firecrawl_search` +3. Add docs and examples +4. If desired, generalize `web_search` provider loading so the extension can back `web_search` +5. Only then consider a true `web_fetch` provider seam diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index 2cd90a06bf5..901890dfb0a 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -1,27 +1,71 @@ --- -summary: "Firecrawl fallback for web_fetch (anti-bot + cached extraction)" +summary: "Firecrawl search, scrape, and web_fetch fallback" read_when: - You want Firecrawl-backed web extraction - You need a Firecrawl API key + - You want Firecrawl as a web_search provider - You want anti-bot extraction for web_fetch title: "Firecrawl" --- # Firecrawl -OpenClaw can use **Firecrawl** as a fallback extractor for `web_fetch`. It is a hosted -content extraction service that supports bot circumvention and caching, which helps -with JS-heavy sites or pages that block plain HTTP fetches. +OpenClaw can use **Firecrawl** in three ways: + +- as the `web_search` provider +- as explicit plugin tools: `firecrawl_search` and `firecrawl_scrape` +- as a fallback extractor for `web_fetch` + +It is a hosted extraction/search service that supports bot circumvention and caching, +which helps with JS-heavy sites or pages that block plain HTTP fetches. ## Get an API key 1. Create a Firecrawl account and generate an API key. 2. Store it in config or set `FIRECRAWL_API_KEY` in the gateway environment. -## Configure Firecrawl +## Configure Firecrawl search ```json5 { + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, + tools: { + web: { + search: { + provider: "firecrawl", + firecrawl: { + apiKey: "FIRECRAWL_API_KEY_HERE", + baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + }, +} +``` + +Notes: + +- Choosing Firecrawl in onboarding or `openclaw configure --section web` enables the bundled Firecrawl plugin automatically. +- `web_search` with Firecrawl supports `query` and `count`. +- For Firecrawl-specific controls like `sources`, `categories`, or result scraping, use `firecrawl_search`. + +## Configure Firecrawl scrape + web_fetch fallback + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, tools: { web: { fetch: { @@ -44,6 +88,38 @@ Notes: - Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`). - `maxAgeMs` controls how old cached results can be (ms). Default is 2 days. +`firecrawl_scrape` reuses the same `tools.web.fetch.firecrawl.*` settings and env vars. + +## Firecrawl plugin tools + +### `firecrawl_search` + +Use this when you want Firecrawl-specific search controls instead of generic `web_search`. + +Core parameters: + +- `query` +- `count` +- `sources` +- `categories` +- `scrapeResults` +- `timeoutSeconds` + +### `firecrawl_scrape` + +Use this for JS-heavy or bot-protected pages where plain `web_fetch` is weak. + +Core parameters: + +- `url` +- `extractMode` +- `maxChars` +- `onlyMainContent` +- `maxAgeMs` +- `proxy` +- `storeInCache` +- `timeoutSeconds` + ## Stealth / bot circumvention Firecrawl exposes a **proxy mode** parameter for bot circumvention (`basic`, `stealth`, or `auto`). diff --git a/docs/tools/index.md b/docs/tools/index.md index bdd9b78456f..dbca6cd26bf 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`). ### `web_search` -Search the web using Perplexity, Brave, Gemini, Grok, or Kimi. +Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, or Perplexity. Core parameters: diff --git a/docs/tools/web.md b/docs/tools/web.md index a2aa1d37bfd..7cc67c07710 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,5 +1,5 @@ --- -summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)" +summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, and Perplexity providers)" read_when: - You want to enable web_search or web_fetch - You need provider API key setup @@ -11,7 +11,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. +- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -24,18 +24,20 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. - `web_fetch` is enabled by default (unless explicitly disabled). +- The bundled Firecrawl plugin also adds `firecrawl_search` and `firecrawl_scrape` when enabled. See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details. ## Choosing a search provider -| Provider | Result shape | Provider-specific filters | Notes | API key | -| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | -| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | -| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | -| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | -| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | -| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | +| Provider | Result shape | Provider-specific filters | Notes | API key | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------- | +| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | +| **Firecrawl Search** | Structured results with snippets | Use `firecrawl_search` for Firecrawl-specific search options | Best for pairing search with Firecrawl scraping/extraction | `FIRECRAWL_API_KEY` | +| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | +| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | +| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | ### Auto-detection @@ -46,6 +48,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut 3. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config 4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config 5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config +6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `tools.web.search.firecrawl.apiKey` config If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -86,6 +89,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks **Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path: - Brave: `tools.web.search.apiKey` +- Firecrawl: `tools.web.search.firecrawl.apiKey` - Gemini: `tools.web.search.gemini.apiKey` - Grok: `tools.web.search.grok.apiKey` - Kimi: `tools.web.search.kimi.apiKey` @@ -96,6 +100,7 @@ All of these fields also support SecretRef objects. **Via environment:** set provider env vars in the Gateway process environment: - Brave: `BRAVE_API_KEY` +- Firecrawl: `FIRECRAWL_API_KEY` - Gemini: `GEMINI_API_KEY` - Grok: `XAI_API_KEY` - Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` @@ -121,6 +126,34 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm } ``` +**Firecrawl Search:** + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "firecrawl", + firecrawl: { + apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set + baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + }, +} +``` + +When you choose Firecrawl in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Firecrawl plugin automatically so `web_search`, `firecrawl_search`, and `firecrawl_scrape` are all available. + **Brave LLM Context mode:** ```json5 @@ -234,6 +267,7 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` + - **Firecrawl**: `FIRECRAWL_API_KEY` or `tools.web.search.firecrawl.apiKey` - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` @@ -260,7 +294,7 @@ Search the web using your configured provider. ### Tool parameters -All parameters work for Brave and for native Perplexity Search API unless noted. +Parameters depend on the selected provider. Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`. If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors. @@ -279,6 +313,8 @@ If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_ | `max_tokens` | Total content budget, default 25000 (Perplexity only) | | `max_tokens_per_page` | Per-page token limit, default 2048 (Perplexity only) | +Firecrawl `web_search` supports `query` and `count`. For Firecrawl-specific controls like `sources`, `categories`, result scraping, or scrape timeout, use `firecrawl_search` from the bundled Firecrawl plugin. + **Examples:** ```javascript diff --git a/extensions/firecrawl/index.test.ts b/extensions/firecrawl/index.test.ts new file mode 100644 index 00000000000..084d3c0c055 --- /dev/null +++ b/extensions/firecrawl/index.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; +import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js"; + +describe("firecrawl plugin", () => { + it("registers a web search provider and tools", () => { + const tools: Array<{ name: string }> = []; + const webSearchProviders: Array<{ id: string }> = []; + + plugin.register?.({ + config: {}, + registerTool(tool: { name: string }) { + tools.push(tool); + }, + registerWebSearchProvider(provider: { id: string }) { + webSearchProviders.push(provider); + }, + } as never); + + expect(webSearchProviders.map((provider) => provider.id)).toEqual(["firecrawl"]); + expect(tools.map((tool) => tool.name)).toEqual(["firecrawl_search", "firecrawl_scrape"]); + }); + + it("parses scrape payloads into wrapped external-content results", () => { + const result = firecrawlClientTesting.parseFirecrawlScrapePayload({ + payload: { + success: true, + data: { + markdown: "# Hello\n\nWorld", + metadata: { + title: "Example page", + sourceURL: "https://example.com/final", + statusCode: 200, + }, + }, + }, + url: "https://example.com/start", + extractMode: "text", + maxChars: 1000, + }); + + expect(result.finalUrl).toBe("https://example.com/final"); + expect(result.status).toBe(200); + expect(result.extractor).toBe("firecrawl"); + expect(typeof result.text).toBe("string"); + }); + + it("extracts search items from flexible Firecrawl payload shapes", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: [ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + markdown: "Body", + }, + ], + }); + + expect(items).toEqual([ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + content: "Body", + published: undefined, + siteName: "docs.example.com", + }, + ]); + }); + + it("extracts search items from Firecrawl v2 data.web payloads", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: { + web: [ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + markdown: "# API Platform", + position: 1, + }, + ], + }, + }); + + expect(items).toEqual([ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + content: "# API Platform", + published: undefined, + siteName: "openai.com", + }, + ]); + }); +}); diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts new file mode 100644 index 00000000000..42bd1a3252f --- /dev/null +++ b/extensions/firecrawl/index.ts @@ -0,0 +1,20 @@ +import type { AnyAgentTool } from "../../src/agents/tools/common.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js"; +import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js"; +import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js"; + +const firecrawlPlugin = { + id: "firecrawl", + name: "Firecrawl Plugin", + description: "Bundled Firecrawl search and scrape plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider(createFirecrawlWebSearchProvider()); + api.registerTool(createFirecrawlSearchTool(api) as AnyAgentTool); + api.registerTool(createFirecrawlScrapeTool(api) as AnyAgentTool); + }, +}; + +export default firecrawlPlugin; diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json new file mode 100644 index 00000000000..52289f0711a --- /dev/null +++ b/extensions/firecrawl/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "firecrawl", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/firecrawl/package.json b/extensions/firecrawl/package.json new file mode 100644 index 00000000000..e891b8293ba --- /dev/null +++ b/extensions/firecrawl/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/firecrawl-plugin", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Firecrawl plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts new file mode 100644 index 00000000000..808b81891f1 --- /dev/null +++ b/extensions/firecrawl/src/config.ts @@ -0,0 +1,159 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; +import { normalizeSecretInput } from "../../../src/utils/normalize-secret-input.js"; + +export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; +export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30; +export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60; +export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000; + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +type WebFetchConfig = NonNullable["web"] extends infer Web + ? Web extends { fetch?: infer Fetch } + ? Fetch + : undefined + : undefined; + +type FirecrawlSearchConfig = + | { + apiKey?: unknown; + baseUrl?: string; + } + | undefined; + +type FirecrawlFetchConfig = + | { + apiKey?: unknown; + baseUrl?: string; + onlyMainContent?: boolean; + maxAgeMs?: number; + timeoutSeconds?: number; + } + | undefined; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig { + const fetch = cfg?.tools?.web?.fetch; + if (!fetch || typeof fetch !== "object") { + return undefined; + } + return fetch as WebFetchConfig; +} + +export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig { + const search = resolveSearchConfig(cfg); + if (!search || typeof search !== "object") { + return undefined; + } + const firecrawl = "firecrawl" in search ? search.firecrawl : undefined; + if (!firecrawl || typeof firecrawl !== "object") { + return undefined; + } + return firecrawl as FirecrawlSearchConfig; +} + +export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetchConfig { + const fetch = resolveFetchConfig(cfg); + if (!fetch || typeof fetch !== "object") { + return undefined; + } + const firecrawl = "firecrawl" in fetch ? fetch.firecrawl : undefined; + if (!firecrawl || typeof firecrawl !== "object") { + return undefined; + } + return firecrawl as FirecrawlFetchConfig; +} + +function normalizeConfiguredSecret(value: unknown, path: string): string | undefined { + return normalizeSecretInput( + normalizeResolvedSecretInputString({ + value, + path, + }), + ); +} + +export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined { + const search = resolveFirecrawlSearchConfig(cfg); + const fetch = resolveFirecrawlFetchConfig(cfg); + return ( + normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") || + normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") || + normalizeSecretInput(process.env.FIRECRAWL_API_KEY) || + undefined + ); +} + +export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string { + const search = resolveFirecrawlSearchConfig(cfg); + const fetch = resolveFirecrawlFetchConfig(cfg); + const configured = + (typeof search?.baseUrl === "string" ? search.baseUrl.trim() : "") || + (typeof fetch?.baseUrl === "string" ? fetch.baseUrl.trim() : "") || + normalizeSecretInput(process.env.FIRECRAWL_BASE_URL) || + ""; + return configured || DEFAULT_FIRECRAWL_BASE_URL; +} + +export function resolveFirecrawlOnlyMainContent(cfg?: OpenClawConfig, override?: boolean): boolean { + if (typeof override === "boolean") { + return override; + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if (typeof fetch?.onlyMainContent === "boolean") { + return fetch.onlyMainContent; + } + return true; +} + +export function resolveFirecrawlMaxAgeMs(cfg?: OpenClawConfig, override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override >= 0) { + return Math.floor(override); + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if ( + typeof fetch?.maxAgeMs === "number" && + Number.isFinite(fetch.maxAgeMs) && + fetch.maxAgeMs >= 0 + ) { + return Math.floor(fetch.maxAgeMs); + } + return DEFAULT_FIRECRAWL_MAX_AGE_MS; +} + +export function resolveFirecrawlScrapeTimeoutSeconds( + cfg?: OpenClawConfig, + override?: number, +): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if ( + typeof fetch?.timeoutSeconds === "number" && + Number.isFinite(fetch.timeoutSeconds) && + fetch.timeoutSeconds > 0 + ) { + return Math.floor(fetch.timeoutSeconds); + } + return DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS; +} + +export function resolveFirecrawlSearchTimeoutSeconds(override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + return DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS; +} diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts new file mode 100644 index 00000000000..2929f2f9dde --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -0,0 +1,446 @@ +import { markdownToText, truncateText } from "../../../src/agents/tools/web-fetch-utils.js"; +import { withTrustedWebToolsEndpoint } from "../../../src/agents/tools/web-guarded-fetch.js"; +import { + DEFAULT_CACHE_TTL_MINUTES, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + writeCache, +} from "../../../src/agents/tools/web-shared.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { wrapExternalContent, wrapWebContent } from "../../../src/security/external-content.js"; +import { + resolveFirecrawlApiKey, + resolveFirecrawlBaseUrl, + resolveFirecrawlMaxAgeMs, + resolveFirecrawlOnlyMainContent, + resolveFirecrawlScrapeTimeoutSeconds, + resolveFirecrawlSearchTimeoutSeconds, +} from "./config.js"; + +const SEARCH_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const SCRAPE_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const DEFAULT_SEARCH_COUNT = 5; +const DEFAULT_SCRAPE_MAX_CHARS = 50_000; +const DEFAULT_ERROR_MAX_BYTES = 64_000; + +type FirecrawlSearchItem = { + title: string; + url: string; + description?: string; + content?: string; + published?: string; + siteName?: string; +}; + +export type FirecrawlSearchParams = { + cfg?: OpenClawConfig; + query: string; + count?: number; + timeoutSeconds?: number; + sources?: string[]; + categories?: string[]; + scrapeResults?: boolean; +}; + +export type FirecrawlScrapeParams = { + cfg?: OpenClawConfig; + url: string; + extractMode: "markdown" | "text"; + maxChars?: number; + onlyMainContent?: boolean; + maxAgeMs?: number; + proxy?: "auto" | "basic" | "stealth"; + storeInCache?: boolean; + timeoutSeconds?: number; +}; + +function resolveEndpoint(baseUrl: string, pathname: "/v2/search" | "/v2/scrape"): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return new URL(pathname, "https://api.firecrawl.dev").toString(); + } + try { + const url = new URL(trimmed); + if (url.pathname && url.pathname !== "/") { + return url.toString(); + } + url.pathname = pathname; + return url.toString(); + } catch { + return new URL(pathname, "https://api.firecrawl.dev").toString(); + } +} + +function resolveSiteName(urlRaw: string): string | undefined { + try { + const host = new URL(urlRaw).hostname.replace(/^www\./, ""); + return host || undefined; + } catch { + return undefined; + } +} + +async function postFirecrawlJson(params: { + baseUrl: string; + pathname: "/v2/search" | "/v2/scrape"; + apiKey: string; + body: Record; + timeoutSeconds: number; + errorLabel: string; +}): Promise> { + const endpoint = resolveEndpoint(params.baseUrl, params.pathname); + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params.body), + }, + }, + async ({ response }) => { + if (!response.ok) { + const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); + throw new Error( + `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, + ); + } + const payload = (await response.json()) as Record; + if (payload.success === false) { + const error = + typeof payload.error === "string" + ? payload.error + : typeof payload.message === "string" + ? payload.message + : "unknown error"; + throw new Error(`${params.errorLabel} API error: ${error}`); + } + return payload; + }, + ); +} + +function resolveSearchItems(payload: Record): FirecrawlSearchItem[] { + const candidates = [ + payload.data, + payload.results, + (payload.data as { results?: unknown } | undefined)?.results, + (payload.data as { data?: unknown } | undefined)?.data, + (payload.data as { web?: unknown } | undefined)?.web, + (payload.web as { results?: unknown } | undefined)?.results, + ]; + const rawItems = candidates.find((candidate) => Array.isArray(candidate)); + if (!Array.isArray(rawItems)) { + return []; + } + const items: FirecrawlSearchItem[] = []; + for (const entry of rawItems) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const metadata = + record.metadata && typeof record.metadata === "object" + ? (record.metadata as Record) + : undefined; + const url = + (typeof record.url === "string" && record.url) || + (typeof record.sourceURL === "string" && record.sourceURL) || + (typeof record.sourceUrl === "string" && record.sourceUrl) || + (typeof metadata?.sourceURL === "string" && metadata.sourceURL) || + ""; + if (!url) { + continue; + } + const title = + (typeof record.title === "string" && record.title) || + (typeof metadata?.title === "string" && metadata.title) || + ""; + const description = + (typeof record.description === "string" && record.description) || + (typeof record.snippet === "string" && record.snippet) || + (typeof record.summary === "string" && record.summary) || + undefined; + const content = + (typeof record.markdown === "string" && record.markdown) || + (typeof record.content === "string" && record.content) || + (typeof record.text === "string" && record.text) || + undefined; + const published = + (typeof record.publishedDate === "string" && record.publishedDate) || + (typeof record.published === "string" && record.published) || + (typeof metadata?.publishedTime === "string" && metadata.publishedTime) || + (typeof metadata?.publishedDate === "string" && metadata.publishedDate) || + undefined; + items.push({ + title, + url, + description, + content, + published, + siteName: resolveSiteName(url), + }); + } + return items; +} + +function buildSearchPayload(params: { + query: string; + provider: "firecrawl"; + items: FirecrawlSearchItem[]; + tookMs: number; + scrapeResults: boolean; +}): Record { + return { + query: params.query, + provider: params.provider, + count: params.items.length, + tookMs: params.tookMs, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: params.items.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + description: entry.description ? wrapWebContent(entry.description, "web_search") : "", + ...(entry.published ? { published: entry.published } : {}), + ...(entry.siteName ? { siteName: entry.siteName } : {}), + ...(params.scrapeResults && entry.content + ? { content: wrapWebContent(entry.content, "web_search") } + : {}), + })), + }; +} + +export async function runFirecrawlSearch( + params: FirecrawlSearchParams, +): Promise> { + const apiKey = resolveFirecrawlApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "web_search (firecrawl) needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.search.firecrawl.apiKey.", + ); + } + const count = + typeof params.count === "number" && Number.isFinite(params.count) + ? Math.max(1, Math.min(10, Math.floor(params.count))) + : DEFAULT_SEARCH_COUNT; + const timeoutSeconds = resolveFirecrawlSearchTimeoutSeconds(params.timeoutSeconds); + const scrapeResults = params.scrapeResults === true; + const sources = Array.isArray(params.sources) ? params.sources.filter(Boolean) : []; + const categories = Array.isArray(params.categories) ? params.categories.filter(Boolean) : []; + const baseUrl = resolveFirecrawlBaseUrl(params.cfg); + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "firecrawl-search", + q: params.query, + count, + baseUrl, + sources, + categories, + scrapeResults, + }), + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const body: Record = { + query: params.query, + limit: count, + }; + if (sources.length > 0) { + body.sources = sources; + } + if (categories.length > 0) { + body.categories = categories; + } + if (scrapeResults) { + body.scrapeOptions = { + formats: ["markdown"], + }; + } + + const start = Date.now(); + const payload = await postFirecrawlJson({ + baseUrl, + pathname: "/v2/search", + apiKey, + body, + timeoutSeconds, + errorLabel: "Firecrawl Search", + }); + const result = buildSearchPayload({ + query: params.query, + provider: "firecrawl", + items: resolveSearchItems(payload), + tookMs: Date.now() - start, + scrapeResults, + }); + writeCache( + SEARCH_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +function resolveScrapeData(payload: Record): Record { + const data = payload.data; + if (data && typeof data === "object") { + return data as Record; + } + return {}; +} + +export function parseFirecrawlScrapePayload(params: { + payload: Record; + url: string; + extractMode: "markdown" | "text"; + maxChars: number; +}): Record { + const data = resolveScrapeData(params.payload); + const metadata = + data.metadata && typeof data.metadata === "object" + ? (data.metadata as Record) + : undefined; + const markdown = + (typeof data.markdown === "string" && data.markdown) || + (typeof data.content === "string" && data.content) || + ""; + if (!markdown) { + throw new Error("Firecrawl scrape returned no content."); + } + const rawText = params.extractMode === "text" ? markdownToText(markdown) : markdown; + const truncated = truncateText(rawText, params.maxChars); + return { + url: params.url, + finalUrl: + (typeof metadata?.sourceURL === "string" && metadata.sourceURL) || + (typeof data.url === "string" && data.url) || + params.url, + status: + (typeof metadata?.statusCode === "number" && metadata.statusCode) || + (typeof data.statusCode === "number" && data.statusCode) || + undefined, + title: + typeof metadata?.title === "string" && metadata.title + ? wrapExternalContent(metadata.title, { source: "web_fetch", includeWarning: false }) + : undefined, + extractor: "firecrawl", + extractMode: params.extractMode, + externalContent: { + untrusted: true, + source: "web_fetch", + wrapped: true, + }, + truncated: truncated.truncated, + rawLength: rawText.length, + wrappedLength: wrapExternalContent(truncated.text, { + source: "web_fetch", + includeWarning: false, + }).length, + text: wrapExternalContent(truncated.text, { + source: "web_fetch", + includeWarning: false, + }), + warning: + typeof params.payload.warning === "string" && params.payload.warning + ? wrapExternalContent(params.payload.warning, { + source: "web_fetch", + includeWarning: false, + }) + : undefined, + }; +} + +export async function runFirecrawlScrape( + params: FirecrawlScrapeParams, +): Promise> { + const apiKey = resolveFirecrawlApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "firecrawl_scrape needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.fetch.firecrawl.apiKey.", + ); + } + const baseUrl = resolveFirecrawlBaseUrl(params.cfg); + const timeoutSeconds = resolveFirecrawlScrapeTimeoutSeconds(params.cfg, params.timeoutSeconds); + const onlyMainContent = resolveFirecrawlOnlyMainContent(params.cfg, params.onlyMainContent); + const maxAgeMs = resolveFirecrawlMaxAgeMs(params.cfg, params.maxAgeMs); + const proxy = params.proxy ?? "auto"; + const storeInCache = params.storeInCache ?? true; + const maxChars = + typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0 + ? Math.floor(params.maxChars) + : DEFAULT_SCRAPE_MAX_CHARS; + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "firecrawl-scrape", + url: params.url, + extractMode: params.extractMode, + baseUrl, + onlyMainContent, + maxAgeMs, + proxy, + storeInCache, + maxChars, + }), + ); + const cached = readCache(SCRAPE_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const payload = await postFirecrawlJson({ + baseUrl, + pathname: "/v2/scrape", + apiKey, + timeoutSeconds, + errorLabel: "Firecrawl", + body: { + url: params.url, + formats: ["markdown"], + onlyMainContent, + timeout: timeoutSeconds * 1000, + maxAge: maxAgeMs, + proxy, + storeInCache, + }, + }); + const result = parseFirecrawlScrapePayload({ + payload, + url: params.url, + extractMode: params.extractMode, + maxChars, + }); + writeCache( + SCRAPE_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +export const __testing = { + parseFirecrawlScrapePayload, + resolveSearchItems, +}; diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts new file mode 100644 index 00000000000..509b3d5fbd6 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -0,0 +1,89 @@ +import { Type } from "@sinclair/typebox"; +import { optionalStringEnum } from "../../../src/agents/schema/typebox.js"; +import { jsonResult, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { runFirecrawlScrape } from "./firecrawl-client.js"; + +const FirecrawlScrapeToolSchema = Type.Object( + { + url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }), + extractMode: optionalStringEnum(["markdown", "text"] as const, { + description: 'Extraction mode ("markdown" or "text"). Default: markdown.', + }), + maxChars: Type.Optional( + Type.Number({ + description: "Maximum characters to return.", + minimum: 100, + }), + ), + onlyMainContent: Type.Optional( + Type.Boolean({ + description: "Keep only main content when Firecrawl supports it.", + }), + ), + maxAgeMs: Type.Optional( + Type.Number({ + description: "Maximum Firecrawl cache age in milliseconds.", + minimum: 0, + }), + ), + proxy: optionalStringEnum(["auto", "basic", "stealth"] as const, { + description: 'Firecrawl proxy mode ("auto", "basic", or "stealth").', + }), + storeInCache: Type.Optional( + Type.Boolean({ + description: "Whether Firecrawl should store the scrape in its cache.", + }), + ), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for the Firecrawl scrape request.", + minimum: 1, + }), + ), + }, + { additionalProperties: false }, +); + +export function createFirecrawlScrapeTool(api: OpenClawPluginApi) { + return { + name: "firecrawl_scrape", + label: "Firecrawl Scrape", + description: + "Scrape a page using Firecrawl v2/scrape. Useful for JS-heavy or bot-protected pages where plain web_fetch is weak.", + parameters: FirecrawlScrapeToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const url = readStringParam(rawParams, "url", { required: true }); + const extractMode = + readStringParam(rawParams, "extractMode") === "text" ? "text" : "markdown"; + const maxChars = readNumberParam(rawParams, "maxChars", { integer: true }); + const maxAgeMs = readNumberParam(rawParams, "maxAgeMs", { integer: true }); + const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", { + integer: true, + }); + const proxyRaw = readStringParam(rawParams, "proxy"); + const proxy = + proxyRaw === "basic" || proxyRaw === "stealth" || proxyRaw === "auto" + ? proxyRaw + : undefined; + const onlyMainContent = + typeof rawParams.onlyMainContent === "boolean" ? rawParams.onlyMainContent : undefined; + const storeInCache = + typeof rawParams.storeInCache === "boolean" ? rawParams.storeInCache : undefined; + + return jsonResult( + await runFirecrawlScrape({ + cfg: api.config, + url, + extractMode, + maxChars, + onlyMainContent, + maxAgeMs, + proxy, + storeInCache, + timeoutSeconds, + }), + ); + }, + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts new file mode 100644 index 00000000000..60489e9618e --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -0,0 +1,63 @@ +import { Type } from "@sinclair/typebox"; +import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js"; +import { runFirecrawlSearch } from "./firecrawl-client.js"; + +const GenericFirecrawlSearchSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }), + ), + }, + { additionalProperties: false }, +); + +function getScopedCredentialValue(searchConfig?: Record): unknown { + const scoped = searchConfig?.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +function setScopedCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + const scoped = searchConfigTarget.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget.firecrawl = { apiKey: value }; + return; + } + (scoped as Record).apiKey = value; +} + +export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + docsUrl: "https://docs.openclaw.ai/tools/firecrawl", + autoDetectOrder: 60, + getCredentialValue: getScopedCredentialValue, + setCredentialValue: setScopedCredentialValue, + createTool: (ctx) => ({ + description: + "Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.", + parameters: GenericFirecrawlSearchSchema, + execute: async (args) => + await runFirecrawlSearch({ + cfg: ctx.config, + query: typeof args.query === "string" ? args.query : "", + count: typeof args.count === "number" ? args.count : undefined, + }), + }), + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-tool.ts b/extensions/firecrawl/src/firecrawl-search-tool.ts new file mode 100644 index 00000000000..f2f133fd7ec --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-tool.ts @@ -0,0 +1,76 @@ +import { Type } from "@sinclair/typebox"; +import { + jsonResult, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { runFirecrawlSearch } from "./firecrawl-client.js"; + +const FirecrawlSearchToolSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }), + ), + sources: Type.Optional( + Type.Array(Type.String(), { + description: 'Optional sources list, for example ["web"], ["news"], or ["images"].', + }), + ), + categories: Type.Optional( + Type.Array(Type.String(), { + description: 'Optional Firecrawl categories, for example ["github"] or ["research"].', + }), + ), + scrapeResults: Type.Optional( + Type.Boolean({ + description: "Include scraped result content when Firecrawl returns it.", + }), + ), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for the Firecrawl Search request.", + minimum: 1, + }), + ), + }, + { additionalProperties: false }, +); + +export function createFirecrawlSearchTool(api: OpenClawPluginApi) { + return { + name: "firecrawl_search", + label: "Firecrawl Search", + description: + "Search the web using Firecrawl v2/search. Can optionally include scraped content from result pages.", + parameters: FirecrawlSearchToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const query = readStringParam(rawParams, "query", { required: true }); + const count = readNumberParam(rawParams, "count", { integer: true }); + const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", { + integer: true, + }); + const sources = readStringArrayParam(rawParams, "sources"); + const categories = readStringArrayParam(rawParams, "categories"); + const scrapeResults = rawParams.scrapeResults === true; + + return jsonResult( + await runFirecrawlSearch({ + cfg: api.config, + query, + count, + timeoutSeconds, + sources, + categories, + scrapeResults, + }), + ); + }, + }; +} diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 4dc57abf80d..86d03650eb6 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -206,27 +206,33 @@ function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boole return false; } +export async function extractBasicHtmlContent(params: { + html: string; + extractMode: ExtractMode; +}): Promise<{ text: string; title?: string } | null> { + const cleanHtml = await sanitizeHtml(params.html); + const rendered = htmlToMarkdown(cleanHtml); + if (params.extractMode === "text") { + const text = + stripInvisibleUnicode(markdownToText(rendered.text)) || + stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); + return text ? { text, title: rendered.title } : null; + } + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: rendered.title } : null; +} + export async function extractReadableContent(params: { html: string; url: string; extractMode: ExtractMode; }): Promise<{ text: string; title?: string } | null> { const cleanHtml = await sanitizeHtml(params.html); - const fallback = (): { text: string; title?: string } => { - const rendered = htmlToMarkdown(cleanHtml); - if (params.extractMode === "text") { - const text = - stripInvisibleUnicode(markdownToText(rendered.text)) || - stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); - return { text, title: rendered.title }; - } - return { text: stripInvisibleUnicode(rendered.text), title: rendered.title }; - }; if ( cleanHtml.length > READABILITY_MAX_HTML_CHARS || exceedsEstimatedHtmlNestingDepth(cleanHtml, READABILITY_MAX_ESTIMATED_NESTING_DEPTH) ) { - return fallback(); + return null; } try { const { Readability, parseHTML } = await loadReadabilityDeps(); @@ -239,16 +245,17 @@ export async function extractReadableContent(params: { const reader = new Readability(document, { charThreshold: 0 }); const parsed = reader.parse(); if (!parsed?.content) { - return fallback(); + return null; } const title = parsed.title || undefined; if (params.extractMode === "text") { const text = stripInvisibleUnicode(normalizeWhitespace(parsed.textContent ?? "")); - return text ? { text, title } : fallback(); + return text ? { text, title } : null; } const rendered = htmlToMarkdown(parsed.content); - return { text: stripInvisibleUnicode(rendered.text), title: title ?? rendered.title }; + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: title ?? rendered.title } : null; } catch { - return fallback(); + return null; } } diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index f4cc88e2d83..92f94bf3a28 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -10,13 +10,14 @@ import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { + extractBasicHtmlContent, extractReadableContent, htmlToMarkdown, markdownToText, truncateText, type ExtractMode, } from "./web-fetch-utils.js"; -import { fetchWithWebToolsNetworkGuard } from "./web-guarded-fetch.js"; +import { fetchWithWebToolsNetworkGuard, withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -26,7 +27,6 @@ import { readResponseText, resolveCacheTtlMs, resolveTimeoutSeconds, - withTimeout, writeCache, } from "./web-shared.js"; @@ -161,11 +161,12 @@ function resolveFirecrawlEnabled(params: { } function resolveFirecrawlBaseUrl(firecrawl?: FirecrawlFetchConfig): string { - const raw = + const fromConfig = firecrawl && "baseUrl" in firecrawl && typeof firecrawl.baseUrl === "string" ? firecrawl.baseUrl.trim() : ""; - return raw || DEFAULT_FIRECRAWL_BASE_URL; + const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_BASE_URL); + return fromConfig || fromEnv || DEFAULT_FIRECRAWL_BASE_URL; } function resolveFirecrawlOnlyMainContent(firecrawl?: FirecrawlFetchConfig): boolean { @@ -381,54 +382,59 @@ export async function fetchFirecrawlContent(params: { proxy: params.proxy, storeInCache: params.storeInCache, }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, }, - body: JSON.stringify(body), - signal: withTimeout(undefined, params.timeoutSeconds * 1000), - }); - - const payload = (await res.json()) as { - success?: boolean; - data?: { - markdown?: string; - content?: string; - metadata?: { - title?: string; - sourceURL?: string; - statusCode?: number; + async ({ response }) => { + const payload = (await response.json()) as { + success?: boolean; + data?: { + markdown?: string; + content?: string; + metadata?: { + title?: string; + sourceURL?: string; + statusCode?: number; + }; + }; + warning?: string; + error?: string; }; - }; - warning?: string; - error?: string; - }; - if (!res.ok || payload?.success === false) { - const detail = payload?.error ?? ""; - throw new Error( - `Firecrawl fetch failed (${res.status}): ${wrapWebContent(detail || res.statusText, "web_fetch")}`.trim(), - ); - } + if (!response.ok || payload?.success === false) { + const detail = payload?.error ?? ""; + throw new Error( + `Firecrawl fetch failed (${response.status}): ${wrapWebContent(detail || response.statusText, "web_fetch")}`.trim(), + ); + } - const data = payload?.data ?? {}; - const rawText = - typeof data.markdown === "string" - ? data.markdown - : typeof data.content === "string" - ? data.content - : ""; - const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; - return { - text, - title: data.metadata?.title, - finalUrl: data.metadata?.sourceURL, - status: data.metadata?.statusCode, - warning: payload?.warning, - }; + const data = payload?.data ?? {}; + const rawText = + typeof data.markdown === "string" + ? data.markdown + : typeof data.content === "string" + ? data.content + : ""; + const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; + return { + text, + title: data.metadata?.title, + finalUrl: data.metadata?.sourceURL, + status: data.metadata?.statusCode, + warning: payload?.warning, + }; + }, + ); } type FirecrawlRuntimeParams = { @@ -629,9 +635,19 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise { expect(authHeader).toBe("Bearer firecrawl-test-key"); }); + it("uses FIRECRAWL_BASE_URL env var when firecrawl.baseUrl is unset", async () => { + vi.stubEnv("FIRECRAWL_BASE_URL", "https://fc.example.com"); + + expect(webFetchTesting.resolveFirecrawlBaseUrl({})).toBe("https://fc.example.com"); + }); + + it("uses guarded endpoint fetch for firecrawl requests", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + + const fetchSpy = installMockFetch((input: RequestInfo | URL) => { + const url = resolveRequestUrl(input); + if (url.includes("api.firecrawl.dev/v2/scrape")) { + return Promise.resolve( + firecrawlResponse("firecrawl guarded transport"), + ) as Promise; + } + return Promise.resolve( + htmlResponse("", url), + ) as Promise; + }); + + const tool = createFirecrawlTool(); + const result = await executeFetch(tool, { url: "https://example.com/guarded-firecrawl" }); + + expect(result?.details).toMatchObject({ extractor: "firecrawl" }); + const firecrawlCall = fetchSpy.mock.calls.find((call) => + resolveRequestUrl(call[0]).includes("/v2/scrape"), + ); + expect(firecrawlCall).toBeTruthy(); + const requestInit = firecrawlCall?.[1] as (RequestInit & { dispatcher?: unknown }) | undefined; + expect(requestInit?.dispatcher).toBeDefined(); + expect(requestInit?.dispatcher).toBeInstanceOf(EnvHttpProxyAgent); + }); + it("throws when readability is disabled and firecrawl is unavailable", async () => { installMockFetch( (input: RequestInfo | URL) => @@ -356,7 +391,29 @@ describe("web_fetch extraction fallbacks", () => { const tool = createFirecrawlTool(); await expect( executeFetch(tool, { url: "https://example.com/readability-empty" }), - ).rejects.toThrow("Readability and Firecrawl returned no content"); + ).rejects.toThrow("Readability, Firecrawl, and basic HTML cleanup returned no content"); + }); + + it("falls back to basic HTML cleanup after readability and before giving up", async () => { + installMockFetch( + (input: RequestInfo | URL) => + Promise.resolve( + htmlResponse( + "Shell App
", + resolveRequestUrl(input), + ), + ) as Promise, + ); + + const tool = createFetchTool({ + firecrawl: { enabled: false }, + }); + const result = await executeFetch(tool, { url: "https://example.com/shell" }); + const details = result?.details as { extractor?: string; text?: string; title?: string }; + + expect(details.extractor).toBe("raw-html"); + expect(details.text).toContain("Shell App"); + expect(details.title).toContain("Shell App"); }); it("uses firecrawl when direct fetch fails", async () => { diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 93451a9d6e9..00bfd6382a6 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -116,6 +116,19 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); }); + it("sets provider and key for firecrawl and enables the plugin", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBe("fc-test-key"); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + }); + it("sets provider and key for grok", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -331,9 +344,9 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); }); - it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { - expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); + it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); - expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity"]); + expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity", "firecrawl"]); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index af5f3cd9a8f..72fafe461d2 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,6 +6,7 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { enablePluginInConfig } from "../plugins/enable.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -15,7 +16,7 @@ export type SearchProvider = NonNullable< NonNullable["web"]>["search"]>["provider"] >; -const SEARCH_PROVIDER_IDS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const SEARCH_PROVIDER_IDS = ["brave", "firecrawl", "gemini", "grok", "kimi", "perplexity"] as const; function isSearchProvider(value: string): value is SearchProvider { return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value); @@ -114,17 +115,21 @@ export function applySearchKey( if (entry) { entry.setCredentialValue(search as Record, key); } - return { + const next = { ...config, tools: { ...config.tools, web: { ...config.tools?.web, search }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { - return { + const next = { ...config, tools: { ...config.tools, @@ -138,6 +143,10 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 9df692962f2..912e70ac5a4 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,6 +16,11 @@ vi.mock("../plugins/web-search-providers.js", () => { envVars: ["BRAVE_API_KEY"], getCredentialValue: (search?: Record) => search?.apiKey, }, + { + id: "firecrawl", + envVars: ["FIRECRAWL_API_KEY"], + getCredentialValue: getScoped("firecrawl"), + }, { id: "gemini", envVars: ["GEMINI_API_KEY"], @@ -75,6 +80,21 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts firecrawl provider and config", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + enabled: true, + provider: "firecrawl", + providerConfig: { + apiKey: "fc-test-key", // pragma: allowlist secret + baseUrl: "https://api.firecrawl.dev", + }, + }), + ); + + expect(res.ok).toBe(true); + }); + it("accepts gemini provider with no extra config", () => { const res = validateConfigObject( buildWebSearchProviderConfig({ @@ -117,6 +137,7 @@ describe("web search provider auto-detection", () => { beforeEach(() => { delete process.env.BRAVE_API_KEY; + delete process.env.FIRECRAWL_API_KEY; delete process.env.GEMINI_API_KEY; delete process.env.KIMI_API_KEY; delete process.env.MOONSHOT_API_KEY; @@ -146,6 +167,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("gemini"); }); + it("auto-detects firecrawl when only FIRECRAWL_API_KEY is set", () => { + process.env.FIRECRAWL_API_KEY = "fc-test-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("firecrawl"); + }); + it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 0d03f9574b1..e5d30070317 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -665,13 +665,17 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", "tools.web.search.brave.mode": 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', + "tools.web.search.firecrawl.apiKey": + "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.search.firecrawl.baseUrl": + 'Firecrawl Search base URL override (default: "https://api.firecrawl.dev").', "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index dc5195fb766..d2c0cb29e48 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -221,6 +221,8 @@ export const FIELD_LABELS: Record = { "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", "tools.web.search.brave.mode": "Brave Search Mode", + "tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret + "tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..d1195ace393 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -457,8 +457,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ - provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + /** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */ + provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: SecretInput; /** Default search results count (1-10). */ @@ -479,6 +479,13 @@ export type ToolsConfig = { /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; + /** Firecrawl-specific configuration (used when provider="firecrawl"). */ + firecrawl?: { + /** Firecrawl API key (defaults to FIRECRAWL_API_KEY env var). */ + apiKey?: SecretInput; + /** Base URL for API requests (defaults to "https://api.firecrawl.dev"). */ + baseUrl?: string; + }; /** Grok-specific configuration (used when provider="grok"). */ grok?: { /** API key for xAI (defaults to XAI_API_KEY env var). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2ee70e58ef6..9ddbedf929e 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -266,6 +266,7 @@ export const ToolsWebSearchSchema = z provider: z .union([ z.literal("brave"), + z.literal("firecrawl"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini"), @@ -301,6 +302,13 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + firecrawl: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + }) + .strict() + .optional(), kimi: z .object({ apiKey: SecretInputSchema.optional().register(sensitive), diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 2e7b79c64d2..26c9f847bf9 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -96,6 +96,7 @@ describe("resolvePluginWebSearchProviders", () => { entries: expect.objectContaining({ openrouter: { enabled: true }, brave: { enabled: true }, + firecrawl: { enabled: true }, google: { enabled: true }, moonshot: { enabled: true }, perplexity: { enabled: true }, diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index f59cf95f51a..c44bb6f2a93 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -11,6 +11,7 @@ const log = createSubsystemLogger("plugins"); const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", + "firecrawl", "google", "moonshot", "perplexity", From ca6dbc0f0acaa0e986c14ad36ebce5a02550e469 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:39:27 -0700 Subject: [PATCH 083/331] Gateway: lazy-load SSH status helpers --- src/commands/gateway-status.ts | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ff2ba419cc8..ecdeeaa9570 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -2,8 +2,6 @@ import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; -import { resolveSshConfig } from "../infra/ssh-config.js"; -import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import type { RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -23,6 +21,19 @@ import { sanitizeSshTarget, } from "./gateway-status/helpers.js"; +let sshConfigModulePromise: Promise | undefined; +let sshTunnelModulePromise: Promise | undefined; + +function loadSshConfigModule() { + sshConfigModulePromise ??= import("../infra/ssh-config.js"); + return sshConfigModulePromise; +} + +function loadSshTunnelModule() { + sshTunnelModulePromise ??= import("../infra/ssh-tunnel.js"); + return sshTunnelModulePromise; +} + export async function gatewayStatusCommand( opts: { url?: string; @@ -87,6 +98,7 @@ export async function gatewayStatusCommand( return null; } try { + const { startSshPortForward } = await loadSshTunnelModule(); const tunnel = await startSshPortForward({ target: sshTarget, identity: sshIdentity ?? undefined, @@ -119,11 +131,13 @@ export async function gatewayStatusCommand( const base = user ? `${user}@${host.trim()}` : host.trim(); return sshPort !== 22 ? `${base}:${sshPort}` : base; }) - .filter((candidate): candidate is string => - Boolean(candidate && parseSshTarget(candidate)), - ); - if (candidates.length > 0) { - sshTarget = candidates[0] ?? null; + .filter((candidate): candidate is string => Boolean(candidate)); + const { parseSshTarget } = await loadSshTunnelModule(); + const validCandidates = candidates.filter((candidate) => + Boolean(parseSshTarget(candidate)), + ); + if (validCandidates.length > 0) { + sshTarget = validCandidates[0] ?? null; } } @@ -420,6 +434,10 @@ async function resolveSshTarget( identity: string | null, overallTimeoutMs: number, ): Promise<{ target: string; identity?: string } | null> { + const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([ + loadSshConfigModule(), + loadSshTunnelModule(), + ]); const parsed = parseSshTarget(rawTarget); if (!parsed) { return null; From 77d0ff629c20488e9eb0ef8ecad729e35033ac8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:18 -0700 Subject: [PATCH 084/331] refactor: rename channel setup flow seam --- extensions/bluebubbles/src/setup-core.ts | 2 +- .../bluebubbles/src/setup-surface.test.ts | 6 +-- extensions/bluebubbles/src/setup-surface.ts | 10 ++-- extensions/discord/src/setup-core.ts | 10 ++-- extensions/discord/src/setup-surface.ts | 14 +++--- .../feishu/src/onboarding.status.test.ts | 4 +- extensions/feishu/src/onboarding.test.ts | 4 +- extensions/feishu/src/setup-surface.ts | 14 +++--- .../googlechat/src/setup-surface.test.ts | 4 +- extensions/googlechat/src/setup-surface.ts | 12 ++--- extensions/imessage/src/setup-core.ts | 14 +++--- extensions/imessage/src/setup-surface.ts | 12 ++--- extensions/irc/src/onboarding.test.ts | 4 +- extensions/irc/src/setup-core.ts | 2 +- extensions/irc/src/setup-surface.ts | 14 +++--- extensions/line/src/setup-surface.test.ts | 6 +-- extensions/line/src/setup-surface.ts | 14 +++--- extensions/matrix/src/setup-surface.ts | 6 +-- extensions/msteams/src/setup-surface.ts | 10 ++-- extensions/nextcloud-talk/src/setup-core.ts | 12 ++--- .../nextcloud-talk/src/setup-surface.ts | 14 +++--- extensions/nostr/src/setup-surface.test.ts | 4 +- extensions/nostr/src/setup-surface.ts | 14 +++--- extensions/signal/src/setup-core.ts | 14 +++--- extensions/signal/src/setup-surface.ts | 12 ++--- extensions/slack/src/setup-core.ts | 10 ++-- extensions/slack/src/setup-surface.ts | 14 +++--- extensions/telegram/src/setup-core.ts | 10 ++-- extensions/telegram/src/setup-surface.ts | 14 +++--- extensions/tlon/src/setup-surface.test.ts | 4 +- extensions/twitch/src/setup-surface.ts | 6 +-- extensions/whatsapp/src/onboarding.test.ts | 4 +- extensions/whatsapp/src/setup-surface.ts | 10 ++-- extensions/zalo/src/onboarding.status.test.ts | 4 +- extensions/zalo/src/setup-surface.test.ts | 4 +- extensions/zalo/src/setup-surface.ts | 8 ++-- extensions/zalouser/src/setup-surface.test.ts | 4 +- extensions/zalouser/src/setup-surface.ts | 8 ++-- ...ers.test.ts => setup-flow-helpers.test.ts} | 48 +++++++++---------- .../helpers.ts => setup-flow-helpers.ts} | 42 ++++++++-------- ...nboarding-types.ts => setup-flow-types.ts} | 30 ++++++------ src/channels/plugins/setup-group-access.ts | 4 +- src/channels/plugins/setup-wizard.ts | 44 ++++++++--------- src/commands/channel-setup/types.ts | 1 + src/commands/onboarding/types.ts | 1 - src/plugin-sdk/bluebubbles.ts | 2 +- src/plugin-sdk/feishu.ts | 4 +- src/plugin-sdk/googlechat.ts | 4 +- src/plugin-sdk/index.ts | 2 +- src/plugin-sdk/irc.ts | 2 +- src/plugin-sdk/matrix.ts | 2 +- src/plugin-sdk/mattermost.ts | 2 +- src/plugin-sdk/msteams.ts | 4 +- src/plugin-sdk/nextcloud-talk.ts | 2 +- src/plugin-sdk/zalo.ts | 2 +- src/plugin-sdk/zalouser.ts | 2 +- 56 files changed, 265 insertions(+), 265 deletions(-) rename src/channels/plugins/{onboarding/helpers.test.ts => setup-flow-helpers.test.ts} (96%) rename src/channels/plugins/{onboarding/helpers.ts => setup-flow-helpers.ts} (94%) rename src/channels/plugins/{onboarding-types.ts => setup-flow-types.ts} (75%) create mode 100644 src/commands/channel-setup/types.ts delete mode 100644 src/commands/onboarding/types.ts diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 930fa29a64e..bea84e6cd2f 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,4 +1,4 @@ -import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index bc9c93735b7..5093c757b06 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { resolveBlueBubblesAccount } from "./accounts.js"; @@ -27,8 +27,8 @@ async function createBlueBubblesConfigureAdapter() { }).config.allowFrom ?? [], }, setup: blueBubblesSetupAdapter, - } as Parameters[0]["plugin"]; - return buildChannelOnboardingAdapterFromSetupWizard({ + } as Parameters[0]["plugin"]; + return buildChannelSetupFlowAdapterFromSetupWizard({ plugin, wizard: blueBubblesSetupWizard, }); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index f4ee2d98db4..a331aec7d43 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + resolveSetupAccountId, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -55,7 +55,7 @@ async function promptBlueBubblesAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg), }); @@ -148,7 +148,7 @@ function validateBlueBubblesWebhookPath(value: string): string | undefined { return undefined; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const dmPolicy: ChannelSetupDmPolicy = { label: "BlueBubbles", channel, policyKey: "channels.bluebubbles.dmPolicy", diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index f75a0312416..f130888cc2b 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,12 +1,12 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -140,7 +140,7 @@ export const discordSetupAdapter: ChannelSetupAdapter = { export function createDiscordSetupWizardProxy( loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, ) { - const discordDmPolicy: ChannelOnboardingDmPolicy = { + const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, policyKey: "channels.discord.dmPolicy", @@ -343,6 +343,6 @@ export function createDiscordSetupWizardProxy( }), }, dmPolicy: discordDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 610b79a5efa..36382eae756 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,14 +1,14 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - resolveOnboardingAccountId, + resolveSetupAccountId, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -59,7 +59,7 @@ async function promptDiscordAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), }); @@ -92,7 +92,7 @@ async function promptDiscordAllowFrom(params: { }); } -const discordDmPolicy: ChannelOnboardingDmPolicy = { +const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, policyKey: "channels.discord.dmPolicy", @@ -273,5 +273,5 @@ export const discordSetupWizard: ChannelSetupWizard = { }), }, dmPolicy: discordDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index 4f3b853a1e2..94488a72bfa 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { feishuPlugin } from "./channel.js"; -const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index 2a444964442..f46aef482ba 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), @@ -56,7 +56,7 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 567ccea1a7e..1c0f966e01e 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, mergeAllowFromEntries, @@ -6,8 +5,9 @@ import { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -115,7 +115,7 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean { async function promptFeishuAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( @@ -136,7 +136,7 @@ async function promptFeishuAllowFrom(params: { initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length === 0) { await params.prompter.note("Enter at least one user.", "Feishu allowlist"); continue; @@ -177,7 +177,7 @@ async function promptFeishuAppId(params: { ).trim(); } -const feishuDmPolicy: ChannelOnboardingDmPolicy = { +const feishuDmPolicy: ChannelSetupDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", @@ -458,7 +458,7 @@ export const feishuSetupWizard: ChannelSetupWizard = { initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, }); if (entry) { - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length > 0) { next = setFeishuGroupAllowFrom(next, parts); } diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts index ab09435f67e..4be1a1bbff0 100644 --- a/extensions/googlechat/src/setup-surface.test.ts +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { googlechatPlugin } from "./channel.js"; @@ -26,7 +26,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const googlechatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: googlechatPlugin, wizard: googlechatPlugin.setupWizard!, }); diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 64fe7837fa3..9b18d2fad4f 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, @@ -48,7 +48,7 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { async function promptAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { const current = params.cfg.channels?.googlechat?.dm?.allowFrom ?? []; const entry = await params.prompter.text({ @@ -57,7 +57,7 @@ async function promptAllowFrom(params: { initialValue: current[0] ? String(current[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); return { ...params.cfg, @@ -76,7 +76,7 @@ async function promptAllowFrom(params: { }; } -const googlechatDmPolicy: ChannelOnboardingDmPolicy = { +const googlechatDmPolicy: ChannelSetupDmPolicy = { label: "Google Chat", channel, policyKey: "channels.googlechat.dm.policy", diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 69a8072bd59..0beb217f305 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -25,7 +25,7 @@ import { normalizeIMessageHandle } from "./targets.js"; const channel = "imessage" as const; export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseSetupEntriesAllowingWildcard(raw, (entry) => { const lower = entry.toLowerCase(); if (lower.startsWith("chat_id:")) { const id = entry.slice("chat_id:".length).trim(); @@ -157,7 +157,7 @@ export const imessageSetupAdapter: ChannelSetupAdapter = { export function createIMessageSetupWizardProxy( loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, ) { - const imessageDmPolicy: ChannelOnboardingDmPolicy = { + const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, policyKey: "channels.imessage.dmPolicy", @@ -231,6 +231,6 @@ export function createIMessageSetupWizardProxy( ], }, dmPolicy: imessageDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 90fcf648e60..722cdb172c4 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -50,7 +50,7 @@ async function promptIMessageAllowFrom(params: { }); } -const imessageDmPolicy: ChannelOnboardingDmPolicy = { +const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, policyKey: "channels.imessage.dmPolicy", @@ -129,7 +129,7 @@ export const imessageSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: imessageDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { imessageSetupAdapter, parseIMessageAllowFromEntries }; diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 38738d1e484..883f15fe1b1 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -1,6 +1,6 @@ import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; @@ -27,7 +27,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: ircPlugin, wizard: ircPlugin.setupWizard!, }); diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 45f9041f973..d1603dee476 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,7 +1,7 @@ import { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index 63a7bec920b..bde9f603593 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - resolveOnboardingAccountId, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + resolveSetupAccountId, + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -165,7 +165,7 @@ async function promptIrcNickServConfig(params: { }); } -const ircDmPolicy: ChannelOnboardingDmPolicy = { +const ircDmPolicy: ChannelSetupDmPolicy = { label: "IRC", channel, policyKey: "channels.irc.dmPolicy", @@ -176,7 +176,7 @@ const ircDmPolicy: ChannelOnboardingDmPolicy = { await promptIrcAllowFrom({ cfg: cfg as CoreConfig, prompter, - accountId: resolveOnboardingAccountId({ + accountId: resolveSetupAccountId({ accountId, defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig), }), @@ -458,7 +458,7 @@ export const ircSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: ircDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { ircSetupAdapter }; diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index 9fbddc19675..01a3024fc3a 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/line"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { listLineAccountIds, resolveDefaultLineAccountId, @@ -30,7 +30,7 @@ function createPrompter(overrides: Partial = {}): WizardPrompter }; } -const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const lineConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: { id: "line", meta: { label: "LINE" }, @@ -41,7 +41,7 @@ const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, }, setup: lineSetupAdapter, - } as Parameters[0]["plugin"], + } as Parameters[0]["plugin"], wizard: lineSetupWizard, }); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 37167723cf7..705c89a44f9 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,9 +1,9 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - setOnboardingChannelEnabled, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { resolveLineAccount } from "../../../src/line/accounts.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -35,7 +35,7 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -const lineDmPolicy: ChannelOnboardingDmPolicy = { +const lineDmPolicy: ChannelSetupDmPolicy = { label: "LINE", channel, policyKey: "channels.line.dmPolicy", @@ -169,7 +169,7 @@ export const lineSetupWizard: ChannelSetupWizard = { placeholder: "U1234567890abcdef1234567890abcdef", invalidWithoutCredentialNote: "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseLineAllowFromId, resolveEntries: async ({ entries }) => entries.map((entry) => { @@ -198,5 +198,5 @@ export const lineSetupWizard: ChannelSetupWizard = { `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ], }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index b475b6bf742..0dcff40fb38 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -242,7 +242,7 @@ const matrixGroupAccess: NonNullable = { setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), }; -const matrixDmPolicy: ChannelOnboardingDmPolicy = { +const matrixDmPolicy: ChannelSetupDmPolicy = { label: "Matrix", channel, policyKey: "channels.matrix.dm.policy", diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 9e39a24563e..8336e0ae976 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; @@ -93,7 +93,7 @@ async function promptMSTeamsAllowFrom(params: { initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length === 0) { await params.prompter.note("Enter at least one user.", "MS Teams allowlist"); continue; @@ -280,7 +280,7 @@ const msteamsGroupAccess: NonNullable = { setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), }; -const msteamsDmPolicy: ChannelOnboardingDmPolicy = { +const msteamsDmPolicy: ChannelSetupDmPolicy = { label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 9deafc5f71a..61ef7e47a85 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, - setOnboardingChannelEnabled, + resolveSetupAccountId, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, @@ -163,7 +163,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), }); @@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 4fcb874b5d3..64c0fc5a7a1 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, - setOnboardingChannelEnabled, + resolveSetupAccountId, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -85,7 +85,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), }); @@ -96,7 +96,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", @@ -272,7 +272,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { }, ], dmPolicy: nextcloudTalkDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { nextcloudTalkSetupAdapter }; diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts index c9c62e14c9a..0bd1b3f29a3 100644 --- a/extensions/nostr/src/setup-surface.test.ts +++ b/extensions/nostr/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { nostrPlugin } from "./channel.js"; @@ -25,7 +25,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const nostrConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const nostrConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: nostrPlugin, wizard: nostrPlugin.setupWizard!, }); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index d58a4c4fbdc..800b2705258 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - parseOnboardingEntriesWithParser, + parseSetupEntriesWithParser, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -76,7 +76,7 @@ function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawCo } function parseRelayUrls(raw: string): { relays: string[]; error?: string } { - const entries = splitOnboardingEntries(raw); + const entries = splitSetupEntries(raw); const relays: string[] = []; for (const entry of entries) { try { @@ -93,7 +93,7 @@ function parseRelayUrls(raw: string): { relays: string[]; error?: string } { } function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesWithParser(raw, (entry) => { + return parseSetupEntriesWithParser(raw, (entry) => { const cleaned = entry.replace(/^nostr:/i, "").trim(); try { return { value: normalizePubkey(cleaned) }; @@ -125,7 +125,7 @@ async function promptNostrAllowFrom(params: { return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); } -const nostrDmPolicy: ChannelOnboardingDmPolicy = { +const nostrDmPolicy: ChannelSetupDmPolicy = { label: "Nostr", channel, policyKey: "channels.nostr.dmPolicy", diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 2f46c4d4c4c..1b5b00d8264 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -51,7 +51,7 @@ function isUuidLike(value: string): boolean { } export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseSetupEntriesAllowingWildcard(raw, (entry) => { if (entry.toLowerCase().startsWith("uuid:")) { const id = entry.slice("uuid:".length).trim(); if (!id) { @@ -186,7 +186,7 @@ export const signalSetupAdapter: ChannelSetupAdapter = { export function createSignalSetupWizardProxy( loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, ) { - const signalDmPolicy: ChannelOnboardingDmPolicy = { + const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -270,6 +270,6 @@ export function createSignalSetupWizardProxy( ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 822df4caf10..62cb02b78ab 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; @@ -56,7 +56,7 @@ async function promptSignalAllowFrom(params: { }); } -const signalDmPolicy: ChannelOnboardingDmPolicy = { +const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -179,7 +179,7 @@ export const signalSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: signalDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index c30f0134009..0aff9fc50a8 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, @@ -6,8 +5,9 @@ import { patchChannelConfigForAccount, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -216,7 +216,7 @@ export const slackSetupAdapter: ChannelSetupAdapter = { export function createSlackSetupWizardProxy( loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, ) { - const slackDmPolicy: ChannelOnboardingDmPolicy = { + const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -490,6 +490,6 @@ export function createSlackSetupWizardProxy( resolved: unknown; }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index dafcad32f74..4088e0d0ceb 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,15 +1,15 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, @@ -166,7 +166,7 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultSlackAccountId(params.cfg), }); @@ -210,7 +210,7 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelOnboardingDmPolicy = { +const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -424,5 +424,5 @@ export const slackSetupWizard: ChannelSetupWizard = { applyAllowlist: ({ cfg, accountId, resolved }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index fe9c9993035..1a3d17e68fd 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,7 +1,7 @@ import { patchChannelConfigForAccount, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -73,7 +73,7 @@ export async function promptTelegramAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: Parameters< NonNullable< - import("../../../src/channels/plugins/onboarding-types.js").ChannelOnboardingDmPolicy["promptAllowFrom"] + import("../../../src/channels/plugins/setup-flow-types.js").ChannelSetupDmPolicy["promptAllowFrom"] > >[0]["prompter"]; accountId?: string; @@ -88,7 +88,7 @@ export async function promptTelegramAllowFromForAccount(params: { ); } const { promptResolvedAllowFrom } = - await import("../../../src/channels/plugins/onboarding/helpers.js"); + await import("../../../src/channels/plugins/setup-flow-helpers.js"); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: resolved.config.allowFrom ?? [], @@ -96,7 +96,7 @@ export async function promptTelegramAllowFromForAccount(params: { message: "Telegram allowFrom (numeric sender id; @username resolves to id)", placeholder: "@username", label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, invalidWithoutTokenNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 3fcf09ed7db..ba03f2bb251 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,10 +1,10 @@ -import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import { type ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; @@ -22,7 +22,7 @@ import { const channel = "telegram" as const; -const dmPolicy: ChannelOnboardingDmPolicy = { +const dmPolicy: ChannelSetupDmPolicy = { label: "Telegram", channel, policyKey: "channels.telegram.dmPolicy", @@ -89,7 +89,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { placeholder: "@username", invalidWithoutCredentialNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, resolveEntries: async ({ credentialValues, entries }) => resolveTelegramAllowFromEntries({ @@ -105,7 +105,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { }), }, dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts index bb638fc3018..9d3f432b46c 100644 --- a/extensions/tlon/src/setup-surface.test.ts +++ b/extensions/tlon/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { tlonPlugin } from "./channel.js"; @@ -26,7 +26,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const tlonConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: tlonPlugin, wizard: tlonPlugin.setupWizard!, }); diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index bff81f47fff..7d4129d2ebd 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,7 +2,7 @@ * Twitch setup wizard surface for CLI setup. */ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -183,7 +183,7 @@ export async function configureWithEnvToken( account: TwitchAccountConfig | null, envToken: string, forceAllowFrom: boolean, - dmPolicy: ChannelOnboardingDmPolicy, + dmPolicy: ChannelSetupDmPolicy, ): Promise<{ cfg: OpenClawConfig } | null> { const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", @@ -247,7 +247,7 @@ function setTwitchGroupPolicy( return setTwitchAccessControl(cfg, allowedRoles, true); } -const twitchDmPolicy: ChannelOnboardingDmPolicy = { +const twitchDmPolicy: ChannelSetupDmPolicy = { label: "Twitch", channel, policyKey: "channels.twitch.allowedRoles", diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/onboarding.test.ts index bf816e3f03d..e28766058af 100644 --- a/extensions/whatsapp/src/onboarding.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; @@ -83,7 +83,7 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } -const whatsappConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const whatsappConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: whatsappPlugin, wizard: whatsappPlugin.setupWizard!, }); diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e0e9fa3191b..e9b5b8aeb0b 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { loginWeb } from "../../../src/channel-web.js"; import { normalizeAllowFromEntries, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-flow-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -96,7 +96,7 @@ async function applyWhatsAppOwnerAllowlist(params: { } function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { - const parts = splitOnboardingEntries(raw); + const parts = splitSetupEntries(raw); if (parts.length === 0) { return { entries: [] }; } @@ -330,7 +330,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = { }); return { cfg: next }; }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index 4db31735c94..65e5591cbae 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { zaloPlugin } from "./channel.js"; -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index 2353a66e453..b5db1019c38 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { zaloPlugin } from "./channel.js"; @@ -18,7 +18,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 125bc322998..b3ad6549c13 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, mergeAllowFromEntries, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; @@ -122,7 +122,7 @@ async function noteZaloTokenHelp( async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -182,7 +182,7 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const zaloDmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelSetupDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index d28fd8f0ccc..bd96ff2efe0 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; vi.mock("./zalo-js.js", async (importOriginal) => { @@ -50,7 +50,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zalouserConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zalouserPlugin, wizard: zalouserPlugin.setupWizard!, }); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 3ce0bd9d066..c7406f50edd 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -91,7 +91,7 @@ async function noteZalouserHelp( async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -144,7 +144,7 @@ async function promptZalouserAllowFrom(params: { } } -const zalouserDmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelSetupDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/setup-flow-helpers.test.ts similarity index 96% rename from src/channels/plugins/onboarding/helpers.test.ts rename to src/channels/plugins/setup-flow-helpers.test.ts index f4d4c0c2f5a..3b24600372c 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/setup-flow-helpers.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); -vi.mock("../../../plugin-sdk/onboarding.js", () => ({ +vi.mock("../../plugin-sdk/onboarding.js", () => ({ promptAccountId: promptAccountIdSdkMock, })); @@ -14,17 +14,17 @@ import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, patchLegacyDmChannelConfig, promptLegacyChannelAllowFrom, - parseOnboardingEntriesWithParser, + parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, @@ -33,9 +33,9 @@ import { setTopLevelChannelGroupPolicy, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "./helpers.js"; + setSetupChannelEnabled, + splitSetupEntries, +} from "./setup-flow-helpers.js"; function createPrompter(inputs: string[]) { return { @@ -464,7 +464,7 @@ describe("promptParsedAllowFromForScopedChannel", () => { message: "msg", placeholder: "placeholder", parseEntries: (raw) => - parseOnboardingEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), + parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), getExistingAllowFrom: ({ cfg }) => cfg.channels?.imessage?.allowFrom ?? [], }); @@ -748,7 +748,7 @@ describe("patchChannelConfigForAccount", () => { }); }); -describe("setOnboardingChannelEnabled", () => { +describe("setSetupChannelEnabled", () => { it("updates enabled and keeps existing channel fields", () => { const cfg: OpenClawConfig = { channels: { @@ -759,13 +759,13 @@ describe("setOnboardingChannelEnabled", () => { }, }; - const next = setOnboardingChannelEnabled(cfg, "discord", false); + const next = setSetupChannelEnabled(cfg, "discord", false); expect(next.channels?.discord?.enabled).toBe(false); expect(next.channels?.discord?.token).toBe("abc"); }); it("creates missing channel config with enabled state", () => { - const next = setOnboardingChannelEnabled({}, "signal", true); + const next = setSetupChannelEnabled({}, "signal", true); expect(next.channels?.signal?.enabled).toBe(true); }); }); @@ -1016,16 +1016,16 @@ describe("setTopLevelChannelGroupPolicy", () => { }); }); -describe("splitOnboardingEntries", () => { +describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { - expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); + expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); }); }); -describe("parseOnboardingEntriesWithParser", () => { +describe("parseSetupEntriesWithParser", () => { it("maps entries and de-duplicates parsed values", () => { expect( - parseOnboardingEntriesWithParser(" alice, ALICE ; * ", (entry) => { + parseSetupEntriesWithParser(" alice, ALICE ; * ", (entry) => { if (entry === "*") { return { value: "*" }; } @@ -1038,7 +1038,7 @@ describe("parseOnboardingEntriesWithParser", () => { it("returns parser errors and clears parsed entries", () => { expect( - parseOnboardingEntriesWithParser("ok, bad", (entry) => + parseSetupEntriesWithParser("ok, bad", (entry) => entry === "bad" ? { error: "invalid entry: bad" } : { value: entry }, ), ).toEqual({ @@ -1048,10 +1048,10 @@ describe("parseOnboardingEntriesWithParser", () => { }); }); -describe("parseOnboardingEntriesAllowingWildcard", () => { +describe("parseSetupEntriesAllowingWildcard", () => { it("preserves wildcard and delegates non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard(" *, Foo ", (entry) => ({ + parseSetupEntriesAllowingWildcard(" *, Foo ", (entry) => ({ value: entry.toLowerCase(), })), ).toEqual({ @@ -1061,7 +1061,7 @@ describe("parseOnboardingEntriesAllowingWildcard", () => { it("returns parser errors for non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard("ok,bad", (entry) => + parseSetupEntriesAllowingWildcard("ok,bad", (entry) => entry === "bad" ? { error: "bad entry" } : { value: entry }, ), ).toEqual({ @@ -1129,10 +1129,10 @@ describe("normalizeAllowFromEntries", () => { }); }); -describe("resolveOnboardingAccountId", () => { +describe("resolveSetupAccountId", () => { it("normalizes provided account ids", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " Work Account ", defaultAccountId: DEFAULT_ACCOUNT_ID, }), @@ -1141,7 +1141,7 @@ describe("resolveOnboardingAccountId", () => { it("falls back to default account id when input is blank", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " ", defaultAccountId: "custom-default", }), diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/setup-flow-helpers.ts similarity index 94% rename from src/channels/plugins/onboarding/helpers.ts rename to src/channels/plugins/setup-flow-helpers.ts index d26999bd3ff..87a208a9a21 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/setup-flow-helpers.ts @@ -1,18 +1,18 @@ import { promptSecretRefForOnboarding, resolveSecretInputModeForEnvSelection, -} from "../../../commands/auth-choice.apply-helpers.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { DmPolicy, GroupPolicy } from "../../../config/types.js"; -import type { SecretInput } from "../../../config/types.secrets.js"; -import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; +} from "../../commands/auth-choice.apply-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { DmPolicy, GroupPolicy } from "../../config/types.js"; +import type { SecretInput } from "../../config/types.secrets.js"; +import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/onboarding.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import type { PromptAccountId, PromptAccountIdParams } from "./setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, -} from "../setup-helpers.js"; +} from "./setup-helpers.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { return await promptAccountIdSdk(params); @@ -34,20 +34,20 @@ export function mergeAllowFromEntries( return [...new Set(merged)]; } -export function splitOnboardingEntries(raw: string): string[] { +export function splitSetupEntries(raw: string): string[] { return raw .split(/[\n,;]+/g) .map((entry) => entry.trim()) .filter(Boolean); } -type ParsedOnboardingEntry = { value: string } | { error: string }; +type ParsedSetupEntry = { value: string } | { error: string }; -export function parseOnboardingEntriesWithParser( +export function parseSetupEntriesWithParser( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - const parts = splitOnboardingEntries(String(raw ?? "")); + const parts = splitSetupEntries(String(raw ?? "")); const entries: string[] = []; for (const part of parts) { const parsed = parseEntry(part); @@ -59,11 +59,11 @@ export function parseOnboardingEntriesWithParser( return { entries: normalizeAllowFromEntries(entries) }; } -export function parseOnboardingEntriesAllowingWildcard( +export function parseSetupEntriesAllowingWildcard( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - return parseOnboardingEntriesWithParser(raw, (entry) => { + return parseSetupEntriesWithParser(raw, (entry) => { if (entry === "*") { return { value: "*" }; } @@ -117,7 +117,7 @@ export function normalizeAllowFromEntries( return [...new Set(normalized)]; } -export function resolveOnboardingAccountId(params: { +export function resolveSetupAccountId(params: { accountId?: string; defaultAccountId: string; }): string { @@ -338,7 +338,7 @@ export function patchLegacyDmChannelConfig(params: { }; } -export function setOnboardingChannelEnabled( +export function setSetupChannelEnabled( cfg: OpenClawConfig, channel: string, enabled: boolean, @@ -656,7 +656,7 @@ export async function promptParsedAllowFromForScopedChannel(params: { accountId: string; }) => Array; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: params.defaultAccountId, }); @@ -799,7 +799,7 @@ export async function promptLegacyChannelAllowFrom(params: { message: params.message, placeholder: params.placeholder, label: params.noteTitle, - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: params.parseId, invalidWithoutTokenNote: params.invalidWithoutTokenNote, resolveEntries: params.resolveEntries, diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/setup-flow-types.ts similarity index 75% rename from src/channels/plugins/onboarding-types.ts rename to src/channels/plugins/setup-flow-types.ts index 8562e6b06a6..a3887cc7ef2 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/setup-flow-types.ts @@ -40,7 +40,7 @@ export type PromptAccountIdParams = { export type PromptAccountId = (params: PromptAccountIdParams) => Promise; -export type ChannelOnboardingStatus = { +export type ChannelSetupStatus = { channel: ChannelId; configured: boolean; statusLines: string[]; @@ -48,13 +48,13 @@ export type ChannelOnboardingStatus = { quickstartScore?: number; }; -export type ChannelOnboardingStatusContext = { +export type ChannelSetupStatusContext = { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; }; -export type ChannelOnboardingConfigureContext = { +export type ChannelSetupConfigureContext = { cfg: OpenClawConfig; runtime: RuntimeEnv; prompter: WizardPrompter; @@ -64,19 +64,19 @@ export type ChannelOnboardingConfigureContext = { forceAllowFrom: boolean; }; -export type ChannelOnboardingResult = { +export type ChannelSetupResult = { cfg: OpenClawConfig; accountId?: string; }; -export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip"; +export type ChannelSetupConfiguredResult = ChannelSetupResult | "skip"; -export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & { +export type ChannelSetupInteractiveContext = ChannelSetupConfigureContext & { configured: boolean; label: string; }; -export type ChannelOnboardingDmPolicy = { +export type ChannelSetupDmPolicy = { label: string; channel: ChannelId; policyKey: string; @@ -90,17 +90,17 @@ export type ChannelOnboardingDmPolicy = { }) => Promise; }; -export type ChannelOnboardingAdapter = { +export type ChannelSetupFlowAdapter = { channel: ChannelId; - getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; - configure: (ctx: ChannelOnboardingConfigureContext) => Promise; + getStatus: (ctx: ChannelSetupStatusContext) => Promise; + configure: (ctx: ChannelSetupConfigureContext) => Promise; configureInteractive?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; + ctx: ChannelSetupInteractiveContext, + ) => Promise; configureWhenConfigured?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; - dmPolicy?: ChannelOnboardingDmPolicy; + ctx: ChannelSetupInteractiveContext, + ) => Promise; + dmPolicy?: ChannelSetupDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; }; diff --git a/src/channels/plugins/setup-group-access.ts b/src/channels/plugins/setup-group-access.ts index a757816e9ec..b9130f7de51 100644 --- a/src/channels/plugins/setup-group-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,10 +1,10 @@ import type { WizardPrompter } from "../../wizard/prompts.js"; -import { splitOnboardingEntries } from "./onboarding/helpers.js"; +import { splitSetupEntries } from "./setup-flow-helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return splitOnboardingEntries(String(raw ?? "")); + return splitSetupEntries(String(raw ?? "")); } export function formatAllowlistEntries(entries: string[]): string { diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 2d4896dd733..66e7765ffe4 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -1,19 +1,19 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfigureContext, - ChannelOnboardingDmPolicy, - ChannelOnboardingStatus, - ChannelOnboardingStatusContext, -} from "./onboarding-types.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, runSingleChannelSecretStep, - splitOnboardingEntries, -} from "./onboarding/helpers.js"; + splitSetupEntries, +} from "./setup-flow-helpers.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfigureContext, + ChannelSetupDmPolicy, + ChannelSetupStatus, + ChannelSetupStatusContext, +} from "./setup-flow-types.js"; import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelSetupInput } from "./types.core.js"; @@ -211,9 +211,9 @@ export type ChannelSetupWizardPrepare = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; }) => | { cfg?: OpenClawConfig; @@ -229,9 +229,9 @@ export type ChannelSetupWizardFinalize = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; forceAllowFrom: boolean; }) => | { @@ -252,7 +252,7 @@ export type ChannelSetupWizard = { resolveAccountIdForConfigure?: (params: { cfg: OpenClawConfig; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; accountOverride?: string; shouldPromptAccountIds: boolean; listAccountIds: ChannelSetupWizardPlugin["config"]["listAccountIds"]; @@ -260,7 +260,7 @@ export type ChannelSetupWizard = { }) => string | Promise; resolveShouldPromptAccountIds?: (params: { cfg: OpenClawConfig; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; shouldPromptAccountIds: boolean; }) => boolean; prepare?: ChannelSetupWizardPrepare; @@ -269,11 +269,11 @@ export type ChannelSetupWizard = { textInputs?: ChannelSetupWizardTextInput[]; finalize?: ChannelSetupWizardFinalize; completionNote?: ChannelSetupWizardNote; - dmPolicy?: ChannelOnboardingDmPolicy; + dmPolicy?: ChannelSetupDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; groupAccess?: ChannelSetupWizardGroupAccess; disable?: (cfg: OpenClawConfig) => OpenClawConfig; - onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; + onAccountRecorded?: ChannelSetupFlowAdapter["onAccountRecorded"]; }; type ChannelSetupWizardPlugin = Pick; @@ -281,8 +281,8 @@ type ChannelSetupWizardPlugin = Pick { + ctx: ChannelSetupStatusContext, +): Promise { const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); const statusLines = (await wizard.status.resolveStatusLines?.({ cfg: ctx.cfg, @@ -399,10 +399,10 @@ async function applyWizardTextInputValue(params: { }).cfg; } -export function buildChannelOnboardingAdapterFromSetupWizard(params: { +export function buildChannelSetupFlowAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; -}): ChannelOnboardingAdapter { +}): ChannelSetupFlowAdapter { const { plugin, wizard } = params; return { channel: plugin.id, @@ -809,7 +809,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { message: allowFrom.message, placeholder: allowFrom.placeholder, label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, - parseInputs: allowFrom.parseInputs ?? splitOnboardingEntries, + parseInputs: allowFrom.parseInputs ?? splitSetupEntries, parseId: allowFrom.parseId, invalidWithoutTokenNote: allowFrom.invalidWithoutCredentialNote, resolveEntries: async ({ entries }) => diff --git a/src/commands/channel-setup/types.ts b/src/commands/channel-setup/types.ts new file mode 100644 index 00000000000..f610d0cb1f6 --- /dev/null +++ b/src/commands/channel-setup/types.ts @@ -0,0 +1 @@ +export * from "../../channels/plugins/setup-flow-types.js"; diff --git a/src/commands/onboarding/types.ts b/src/commands/onboarding/types.ts deleted file mode 100644 index fb0430abda0..00000000000 --- a/src/commands/onboarding/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../channels/plugins/onboarding-types.js"; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 4527f24917d..3c8fc8c194c 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -35,7 +35,7 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 246185f404e..03c48b7e414 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -21,8 +21,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 42ad2eb032f..130b3d2fc14 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -27,9 +27,9 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - splitOnboardingEntries, + splitSetupEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 699d0778522..ba5583d2c4a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -229,7 +229,7 @@ export { export { promptSingleChannelSecretInput, type SingleChannelSecretInputPromptResult, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index c74aab071ca..2b2a86badda 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -17,7 +17,7 @@ export { addWildcardAllowFrom, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { patchScopedAccountConfig } from "../channels/plugins/setup-helpers.js"; export type { BaseProbeResult } from "../channels/plugins/types.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 8a62aa9ae10..58234ca86fe 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -38,7 +38,7 @@ export { mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 6cfeeacd918..4787d5e8ac3 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -32,7 +32,7 @@ export { buildSingleChannelSecretPromptState, promptSingleChannelSecretInput, runSingleChannelSecretStep, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 2f5a91d8989..96e296af04a 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -38,8 +38,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index f0d2e1de29d..960ac32af0b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -24,7 +24,7 @@ export { promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 9f680ce6b0e..775f2817ca1 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -18,7 +18,7 @@ export { promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 5dba9c0aa77..9e4910b1c85 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -15,7 +15,7 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, From de503dbcbbd82e21a2c2630ca421fbba78820aec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:29 -0700 Subject: [PATCH 085/331] refactor: move setup fallback into setup registry --- extensions/line/setup-entry.ts | 5 ++ extensions/line/src/channel.setup.ts | 69 +++++++++++++++++ src/channels/plugins/setup-registry.ts | 48 +++++++++--- src/commands/channel-setup/registry.ts | 94 ++++------------------- src/commands/channel-test-helpers.ts | 28 +++---- src/commands/channels/add.ts | 2 +- src/commands/onboard-channels.e2e.test.ts | 14 ++-- src/commands/onboard-channels.ts | 87 ++++++++++----------- 8 files changed, 187 insertions(+), 160 deletions(-) create mode 100644 extensions/line/setup-entry.ts create mode 100644 extensions/line/src/channel.setup.ts diff --git a/extensions/line/setup-entry.ts b/extensions/line/setup-entry.ts new file mode 100644 index 00000000000..ca25d243155 --- /dev/null +++ b/extensions/line/setup-entry.ts @@ -0,0 +1,5 @@ +import { lineSetupPlugin } from "./src/channel.setup.js"; + +export default { + plugin: lineSetupPlugin, +}; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts new file mode 100644 index 00000000000..71a1d87c45d --- /dev/null +++ b/extensions/line/src/channel.setup.ts @@ -0,0 +1,69 @@ +import { + buildChannelConfigSchema, + LineConfigSchema, + type ChannelPlugin, + type OpenClawConfig, + type ResolvedLineAccount, +} from "openclaw/plugin-sdk/line"; +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupWizard } from "./setup-surface.js"; + +const meta = { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + systemImage: "message.fill", +} as const; + +const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, ""); + +export const lineSetupPlugin: ChannelPlugin = { + id: "line", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.line"] }, + configSchema: buildChannelConfigSchema(LineConfigSchema), + config: { + listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg), + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }), + defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg), + isConfigured: (account) => + Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + tokenSource: account.tokenSource ?? undefined, + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => normalizeLineAllowFrom(entry)), + }, + setupWizard: lineSetupWizard, + setup: lineSetupAdapter, +}; diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index 493b14351cc..a8c7212ca1f 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,3 +1,12 @@ +import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; +import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; +import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; import { getActivePluginRegistryVersion, requireActivePluginRegistry, @@ -19,6 +28,18 @@ const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; +const BUNDLED_CHANNEL_SETUP_PLUGINS = [ + telegramSetupPlugin, + whatsappSetupPlugin, + discordSetupPlugin, + ircPlugin, + googlechatPlugin, + slackSetupPlugin, + signalSetupPlugin, + imessageSetupPlugin, + lineSetupPlugin, +] as ChannelPlugin[]; + function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -33,17 +54,8 @@ function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { return resolved; } -function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { - const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); - const cached = cachedChannelSetupPlugins; - if (cached.registryVersion === registryVersion) { - return cached; - } - - const sorted = dedupeSetupPlugins( - (registry.channelSetups ?? []).map((entry) => entry.plugin), - ).toSorted((a, b) => { +function sortChannelSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { + return dedupeSetupPlugins(plugins).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); @@ -53,6 +65,20 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { } return a.id.localeCompare(b.id); }); +} + +function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { + const registry = requireActivePluginRegistry(); + const registryVersion = getActivePluginRegistryVersion(); + const cached = cachedChannelSetupPlugins; + if (cached.registryVersion === registryVersion) { + return cached; + } + + const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); + const sorted = sortChannelSetupPlugins( + registryPlugins.length > 0 ? registryPlugins : BUNDLED_CHANNEL_SETUP_PLUGINS, + ); const byId = new Map(); for (const plugin of sorted) { byId.set(plugin.id, plugin); diff --git a/src/commands/channel-setup/registry.ts b/src/commands/channel-setup/registry.ts index bedc2f9bf6d..9bfd1cf188b 100644 --- a/src/commands/channel-setup/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -1,46 +1,20 @@ -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { ircPlugin } from "../../../extensions/irc/src/channel.js"; -import { linePlugin } from "../../../extensions/line/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; -import type { ChannelOnboardingAdapter } from "../onboarding/types.js"; +import type { ChannelSetupFlowAdapter } from "./types.js"; -const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ - telegramPlugin, - whatsappPlugin, - discordPlugin, - ircPlugin, - googlechatPlugin, - slackPlugin, - signalPlugin, - imessagePlugin, - linePlugin, -]; +const setupWizardAdapters = new WeakMap(); -export type ChannelOnboardingSetupPlugin = Pick< - ChannelPlugin, - "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" ->; - -const setupWizardAdapters = new WeakMap(); - -export function resolveChannelOnboardingAdapterForPlugin( - plugin?: ChannelOnboardingSetupPlugin, -): ChannelOnboardingAdapter | undefined { +export function resolveChannelSetupFlowAdapterForPlugin( + plugin?: ChannelPlugin, +): ChannelSetupFlowAdapter | undefined { if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); if (cached) { return cached; } - const adapter = buildChannelOnboardingAdapterFromSetupWizard({ + const adapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin, wizard: plugin.setupWizard, }); @@ -50,15 +24,10 @@ export function resolveChannelOnboardingAdapterForPlugin( return undefined; } -const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map(); - const setupPlugins = listChannelSetupPlugins(); - const plugins = - setupPlugins.length > 0 - ? setupPlugins - : (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType); - for (const plugin of plugins) { - const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); +const CHANNEL_SETUP_FLOW_ADAPTERS = () => { + const adapters = new Map(); + for (const plugin of listChannelSetupPlugins()) { + const adapter = resolveChannelSetupFlowAdapterForPlugin(plugin); if (!adapter) { continue; } @@ -67,43 +36,12 @@ const CHANNEL_ONBOARDING_ADAPTERS = () => { return adapters; }; -export function getChannelOnboardingAdapter( +export function getChannelSetupFlowAdapter( channel: ChannelChoice, -): ChannelOnboardingAdapter | undefined { - return CHANNEL_ONBOARDING_ADAPTERS().get(channel); +): ChannelSetupFlowAdapter | undefined { + return CHANNEL_SETUP_FLOW_ADAPTERS().get(channel); } -export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { - return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); +export function listChannelSetupFlowAdapters(): ChannelSetupFlowAdapter[] { + return Array.from(CHANNEL_SETUP_FLOW_ADAPTERS().values()); } - -export async function loadBundledChannelOnboardingPlugin( - channel: ChannelChoice, -): Promise { - switch (channel) { - case "discord": - return discordPlugin as ChannelPlugin; - case "googlechat": - return googlechatPlugin as ChannelPlugin; - case "imessage": - return imessagePlugin as ChannelPlugin; - case "irc": - return ircPlugin as ChannelPlugin; - case "line": - return linePlugin as ChannelPlugin; - case "signal": - return signalPlugin as ChannelPlugin; - case "slack": - return slackPlugin as ChannelPlugin; - case "telegram": - return telegramPlugin as ChannelPlugin; - case "whatsapp": - return whatsappPlugin as ChannelPlugin; - default: - return undefined; - } -} - -// Legacy aliases (pre-rename). -export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; -export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 97167228e7f..7a6d687a91c 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,22 +6,22 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; -import { getChannelOnboardingAdapter } from "./channel-setup/registry.js"; +import { getChannelSetupFlowAdapter } from "./channel-setup/registry.js"; +import type { ChannelSetupFlowAdapter } from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; -import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; -type ChannelOnboardingAdapterPatch = Partial< +type ChannelSetupFlowAdapterPatch = Partial< Pick< - ChannelOnboardingAdapter, + ChannelSetupFlowAdapter, "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus" > >; -type PatchedOnboardingAdapterFields = { - configure?: ChannelOnboardingAdapter["configure"]; - configureInteractive?: ChannelOnboardingAdapter["configureInteractive"]; - configureWhenConfigured?: ChannelOnboardingAdapter["configureWhenConfigured"]; - getStatus?: ChannelOnboardingAdapter["getStatus"]; +type PatchedSetupAdapterFields = { + configure?: ChannelSetupFlowAdapter["configure"]; + configureInteractive?: ChannelSetupFlowAdapter["configureInteractive"]; + configureWhenConfigured?: ChannelSetupFlowAdapter["configureWhenConfigured"]; + getStatus?: ChannelSetupFlowAdapter["getStatus"]; }; export function setDefaultChannelPluginRegistryForTests(): void { @@ -36,16 +36,16 @@ export function setDefaultChannelPluginRegistryForTests(): void { setActivePluginRegistry(createTestRegistry(channels)); } -export function patchChannelOnboardingAdapter( +export function patchChannelSetupFlowAdapter( channel: ChannelChoice, - patch: ChannelOnboardingAdapterPatch, + patch: ChannelSetupFlowAdapterPatch, ): () => void { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getChannelSetupFlowAdapter(channel); if (!adapter) { - throw new Error(`missing onboarding adapter for ${channel}`); + throw new Error(`missing setup adapter for ${channel}`); } - const previous: PatchedOnboardingAdapterFields = {}; + const previous: PatchedSetupAdapterFields = {}; if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) { previous.getStatus = adapter.getStatus; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 30fe44f1b54..d4175cf100b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,7 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 0f2fb4c2e1e..faf1e7cfb7e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -5,7 +5,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { - patchChannelOnboardingAdapter, + patchChannelSetupFlowAdapter, setDefaultChannelPluginRegistryForTests, } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; @@ -96,8 +96,8 @@ function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig } as OpenClawConfig; } -function patchTelegramAdapter(overrides: Parameters[1]) { - return patchChannelOnboardingAdapter("telegram", { +function patchTelegramAdapter(overrides: Parameters[1]) { + return patchChannelSetupFlowAdapter("telegram", { ...overrides, getStatus: overrides.getStatus ?? @@ -277,7 +277,7 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); - it("continues Telegram onboarding even when plugin registry is empty (avoids 'plugin not available' block)", async () => { + it("continues Telegram setup when the plugin registry is empty", async () => { // Simulate missing registry entries (the scenario reported in #25545). setActivePluginRegistry(createEmptyPluginRegistry()); // Avoid accidental env-token configuration changing the prompt path. @@ -311,11 +311,7 @@ describe("setupChannels", () => { ); }); expect(sawHardStop).toBe(false); - expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - }), - ); + expect(loadOnboardingPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled(); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index ffc4932f7b8..67c78e7a72c 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/setup-flow-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, @@ -21,23 +21,20 @@ import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; -import { - loadBundledChannelOnboardingPlugin, - resolveChannelOnboardingAdapterForPlugin, -} from "./channel-setup/registry.js"; +import { resolveChannelSetupFlowAdapterForPlugin } from "./channel-setup/registry.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfiguredResult, + ChannelSetupDmPolicy, + ChannelSetupResult, + ChannelSetupStatus, + SetupChannelsOptions, +} from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfiguredResult, - ChannelOnboardingDmPolicy, - ChannelOnboardingResult, - ChannelOnboardingStatus, - SetupChannelsOptions, -} from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; @@ -45,7 +42,7 @@ type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; installedCatalogEntries: ReturnType; - statusByChannel: Map; + statusByChannel: Map; statusLines: string[]; }; @@ -122,7 +119,7 @@ async function collectChannelStatus(params: { options?: SetupChannelsOptions; accountOverrides: Partial>; installedPlugins?: ChannelOnboardingSetupPlugin[]; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); @@ -134,7 +131,7 @@ async function collectChannelStatus(params: { const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => - resolveChannelOnboardingAdapterForPlugin( + resolveChannelSetupFlowAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); const statusEntries = await Promise.all( @@ -274,13 +271,13 @@ async function maybeConfigureDmPolicies(params: { selection: ChannelChoice[]; prompter: WizardPrompter; accountIdsByChannel?: Map; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection - .map((channel) => resolve?.(channel)?.dmPolicy) - .filter(Boolean) as ChannelOnboardingDmPolicy[]; + .map((channel) => resolve(channel)?.dmPolicy) + .filter(Boolean) as ChannelSetupDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; } @@ -294,7 +291,7 @@ async function maybeConfigureDmPolicies(params: { } let cfg = params.cfg; - const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => { + const selectPolicy = async (policy: ChannelSetupDmPolicy) => { await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", @@ -337,7 +334,7 @@ async function maybeConfigureDmPolicies(params: { return cfg; } -// Channel-specific prompts moved into onboarding adapters. +// Channel-specific prompts moved into setup flow adapters. export async function setupChannels( cfg: OpenClawConfig, @@ -393,21 +390,17 @@ export async function setupChannels( rememberScopedPlugin(plugin); return plugin; } - const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); - if (bundledPlugin) { - rememberScopedPlugin(bundledPlugin); - } - return bundledPlugin; + return undefined; }; - const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { + const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => { const scopedPlugin = scopedPluginsById.get(channel); if (scopedPlugin) { - return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); + return resolveChannelSetupFlowAdapterForPlugin(scopedPlugin); } - return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); + return resolveChannelSetupFlowAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { - // Keep onboarding memory bounded by snapshot-loading only configured external plugins. + // Keep setup memory bounded by snapshot-loading only configured external plugins. const workspaceDir = resolveWorkspaceDir(); for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { const channel = entry.id as ChannelChoice; @@ -438,7 +431,7 @@ export async function setupChannels( options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleOnboardingAdapter, + resolveAdapter: getVisibleSetupFlowAdapter, }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); @@ -493,7 +486,7 @@ export async function setupChannels( const accountIdsByChannel = new Map(); const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); accountIdsByChannel.set(channel, accountId); }; @@ -566,7 +559,7 @@ export async function setupChannels( }; const refreshStatus = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { return; } @@ -589,11 +582,11 @@ export async function setupChannels( return false; } const plugin = await loadScopedChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!plugin) { if (adapter) { await prompter.note( - `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + `${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand( "openclaw plugins list", )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, "Channel setup", @@ -608,7 +601,7 @@ export async function setupChannels( return true; }; - const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => { + const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { next = result.cfg; if (result.accountId) { recordAccount(channel, result.accountId); @@ -617,21 +610,21 @@ export async function setupChannels( await refreshStatus(channel); }; - const applyCustomOnboardingResult = async ( + const applyCustomSetupResult = async ( channel: ChannelChoice, - result: ChannelOnboardingConfiguredResult, + result: ChannelSetupConfiguredResult, ) => { if (result === "skip") { return false; } - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); return true; }; const configureChannel = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { - await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup"); + await prompter.note(`${channel} does not support guided setup yet.`, "Channel setup"); return; } const result = await adapter.configure({ @@ -643,12 +636,12 @@ export async function setupChannels( shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, @@ -661,7 +654,7 @@ export async function setupChannels( configured: true, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -772,7 +765,7 @@ export async function setupChannels( } const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; @@ -788,7 +781,7 @@ export async function setupChannels( configured, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -861,7 +854,7 @@ export async function setupChannels( selection, prompter, accountIdsByChannel, - resolveAdapter: getVisibleOnboardingAdapter, + resolveAdapter: getVisibleSetupFlowAdapter, }); } From 371366e9eb6b0a8528d5c5a1362d950575a99a94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:37 -0700 Subject: [PATCH 086/331] feat: add synology chat setup wizard --- extensions/synology-chat/index.ts | 4 +- extensions/synology-chat/package.json | 1 + extensions/synology-chat/setup-entry.ts | 5 + extensions/synology-chat/src/channel.test.ts | 2 + extensions/synology-chat/src/channel.ts | 5 + .../synology-chat/src/setup-surface.test.ts | 101 ++++++ extensions/synology-chat/src/setup-surface.ts | 324 ++++++++++++++++++ src/plugin-sdk/subpaths.test.ts | 6 + src/plugin-sdk/synology-chat.ts | 6 + 9 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 extensions/synology-chat/setup-entry.ts create mode 100644 extensions/synology-chat/src/setup-surface.test.ts create mode 100644 extensions/synology-chat/src/setup-surface.ts diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 69dbfb9edbf..9078b9f86c7 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/synology-chat"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/synology-chat"; -import { createSynologyChatPlugin } from "./src/channel.js"; +import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setSynologyRuntime(api.runtime); - api.registerChannel({ plugin: createSynologyChatPlugin() }); + api.registerChannel({ plugin: synologyChatPlugin }); }, }; diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index c6148c856a3..d8ff22d6361 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "synology-chat", "label": "Synology Chat", diff --git a/extensions/synology-chat/setup-entry.ts b/extensions/synology-chat/setup-entry.ts new file mode 100644 index 00000000000..45cc966e082 --- /dev/null +++ b/extensions/synology-chat/setup-entry.ts @@ -0,0 +1,5 @@ +import { synologyChatPlugin } from "./src/channel.js"; + +export default { + plugin: synologyChatPlugin, +}; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index bdce5f37d79..b45f8c355e4 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -22,6 +22,8 @@ describe("createSynologyChatPlugin", () => { expect(plugin.meta).toBeDefined(); expect(plugin.capabilities).toBeDefined(); expect(plugin.config).toBeDefined(); + expect(plugin.setup).toBeDefined(); + expect(plugin.setupWizard).toBeDefined(); expect(plugin.security).toBeDefined(); expect(plugin.outbound).toBeDefined(); expect(plugin.gateway).toBeDefined(); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index d84516dbda5..0bc771a7d26 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; +import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { createWebhookHandler } from "./webhook-handler.js"; @@ -68,6 +69,8 @@ export function createSynologyChatPlugin() { reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SynologyChatConfigSchema, + setup: synologyChatSetupAdapter, + setupWizard: synologyChatSetupWizard, config: { listAccountIds: (cfg: any) => listAccountIds(cfg), @@ -377,3 +380,5 @@ export function createSynologyChatPlugin() { }, }; } + +export const synologyChatPlugin = createSynologyChatPlugin(); diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts new file mode 100644 index 00000000000..d7a2a1056a0 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { synologyChatPlugin } from "./channel.js"; +import { synologyChatSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const synologyChatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ + plugin: synologyChatPlugin, + wizard: synologyChatSetupWizard, +}); + +describe("synology-chat setup wizard", () => { + it("configures token and incoming webhook for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.["synology-chat"]?.enabled).toBe(true); + expect(result.cfg.channels?.["synology-chat"]?.token).toBe("synology-token"); + expect(result.cfg.channels?.["synology-chat"]?.incomingUrl).toBe( + "https://nas.example.com/webapi/entry.cgi?token=incoming", + ); + }); + + it("records allowed user ids when setup forces allowFrom", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + if (message === "Allowed Synology Chat user ids") { + return "123456, synology-chat:789012"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.["synology-chat"]?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.["synology-chat"]?.allowedUserIds).toEqual(["123456", "789012"]); + }); +}); diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts new file mode 100644 index 00000000000..77ad0ded2c2 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.ts @@ -0,0 +1,324 @@ +import { + mergeAllowFromEntries, + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listAccountIds, resolveAccount } from "./accounts.js"; +import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; + +const channel = "synology-chat" as const; +const DEFAULT_WEBHOOK_PATH = "/webhook/synology"; + +const SYNOLOGY_SETUP_HELP_LINES = [ + "1) Create an incoming webhook in Synology Chat and copy its URL", + "2) Create an outgoing webhook and copy its secret token", + `3) Point the outgoing webhook to https://${DEFAULT_WEBHOOK_PATH}`, + "4) Keep allowed user IDs handy for DM allowlisting", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +const SYNOLOGY_ALLOW_FROM_HELP_LINES = [ + "Allowlist Synology Chat DMs by numeric user id.", + "Examples:", + "- 123456", + "- synology-chat:123456", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig { + return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {}; +} + +function getRawAccountConfig(cfg: OpenClawConfig, accountId: string): SynologyChatAccountRaw { + const channelConfig = getChannelConfig(cfg); + if (accountId === DEFAULT_ACCOUNT_ID) { + return channelConfig; + } + return channelConfig.accounts?.[accountId] ?? {}; +} + +function patchSynologyChatAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = getChannelConfig(params.cfg); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + const nextChannelConfig = { ...channelConfig } as Record; + for (const field of params.clearFields ?? []) { + delete nextChannelConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...nextChannelConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccounts = { ...(channelConfig.accounts ?? {}) } as Record< + string, + Record + >; + const nextAccountConfig = { ...(nextAccounts[params.accountId] ?? {}) }; + for (const field of params.clearFields ?? []) { + delete nextAccountConfig[field]; + } + nextAccounts[params.accountId] = { + ...nextAccountConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }; + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: nextAccounts, + }, + }, + }; +} + +function isSynologyChatConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const account = resolveAccount(cfg, accountId); + return Boolean(account.token.trim() && account.incomingUrl.trim()); +} + +function validateWebhookUrl(value: string): string | undefined { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Incoming webhook must use http:// or https://."; + } + } catch { + return "Incoming webhook must be a valid URL."; + } + return undefined; +} + +function validateWebhookPath(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.startsWith("/") ? undefined : "Webhook path must start with /."; +} + +function parseSynologyUserId(value: string): string | null { + const cleaned = value.replace(/^synology-chat:/i, "").trim(); + return /^\d+$/.test(cleaned) ? cleaned : null; +} + +function resolveExistingAllowedUserIds(cfg: OpenClawConfig, accountId: string): string[] { + const raw = getRawAccountConfig(cfg, accountId).allowedUserIds; + if (Array.isArray(raw)) { + return raw.map((value) => String(value).trim()).filter(Boolean); + } + return String(raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export const synologyChatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID, + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Synology Chat env credentials only support the default account."; + } + if (!input.useEnv && !input.token?.trim()) { + return "Synology Chat requires --token or --use-env."; + } + if (!input.url?.trim()) { + return "Synology Chat requires --url for the incoming webhook."; + } + const urlError = validateWebhookUrl(input.url.trim()); + if (urlError) { + return urlError; + } + if (input.webhookPath?.trim()) { + return validateWebhookPath(input.webhookPath.trim()) ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: input.useEnv ? ["token"] : undefined, + patch: { + ...(input.useEnv ? {} : { token: input.token?.trim() }), + incomingUrl: input.url?.trim(), + ...(input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}), + }, + }), +}; + +export const synologyChatSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + incoming webhook", + configuredHint: "configured", + unconfiguredHint: "needs token + incoming webhook", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listAccountIds(cfg).some((accountId) => isSynologyChatConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `Synology Chat: ${configured ? "configured" : "needs token + incoming webhook"}`, + `Accounts: ${listAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "Synology Chat webhook setup", + lines: SYNOLOGY_SETUP_HELP_LINES, + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "outgoing webhook token", + preferredEnvVar: "SYNOLOGY_CHAT_TOKEN", + helpTitle: "Synology Chat webhook token", + helpLines: SYNOLOGY_SETUP_HELP_LINES, + envPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?", + keepPrompt: "Synology Chat webhook token already configured. Keep it?", + inputPrompt: "Enter Synology Chat outgoing webhook token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveAccount(cfg, accountId); + const raw = getRawAccountConfig(cfg, accountId); + return { + accountConfigured: isSynologyChatConfigured(cfg, accountId), + hasConfiguredValue: Boolean(raw.token?.trim()), + resolvedValue: account.token.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.SYNOLOGY_CHAT_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async ({ cfg, accountId }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["token"], + patch: {}, + }), + applySet: async ({ cfg, accountId, resolvedValue }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { token: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "url", + message: "Incoming webhook URL", + placeholder: + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...", + helpTitle: "Synology Chat incoming webhook", + helpLines: [ + "Use the incoming webhook URL from Synology Chat integrations.", + "This is the URL OpenClaw uses to send replies back to Chat.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(), + keepPrompt: (value) => `Incoming webhook URL set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookUrl(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { incomingUrl: value.trim() }, + }), + }, + { + inputKey: "webhookPath", + message: "Outgoing webhook path (optional)", + placeholder: DEFAULT_WEBHOOK_PATH, + required: false, + applyEmptyValue: true, + helpTitle: "Synology Chat outgoing webhook path", + helpLines: [ + `Default path: ${DEFAULT_WEBHOOK_PATH}`, + "Change this only if you need multiple Synology Chat webhook routes.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(), + keepPrompt: (value) => `Outgoing webhook path set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookPath(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: value.trim() ? undefined : ["webhookPath"], + patch: value.trim() ? { webhookPath: value.trim() } : {}, + }), + }, + ], + allowFrom: { + helpTitle: "Synology Chat allowlist", + helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES, + message: "Allowed Synology Chat user ids", + placeholder: "123456, 987654", + invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.", + parseInputs: splitSetupEntries, + parseId: parseSynologyUserId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseSynologyUserId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { + dmPolicy: "allowlist", + allowedUserIds: mergeAllowFromEntries( + resolveExistingAllowedUserIds(cfg, accountId), + allowFrom, + ), + }, + }), + }, + completionNote: { + title: "Synology Chat access control", + lines: [ + `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`, + 'Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `"open"` for public DMs.', + 'With `dmPolicy="allowlist"`, an empty allowedUserIds list blocks the route from starting.', + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, + ], + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index a483e5aaf30..8a57148f430 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -111,6 +111,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); + it("exports Synology Chat helpers", async () => { + const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); + expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); + expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); + }); + it("exports Zalouser helpers", async () => { const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index dcce2ea760b..f5fae73fbb2 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -3,6 +3,7 @@ export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { isRequestBodyLimitError, readRequestBodyWithLimit, @@ -10,8 +11,13 @@ export { } from "../infra/http-body.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +export { + synologyChatSetupAdapter, + synologyChatSetupWizard, +} from "../../extensions/synology-chat/src/setup-surface.js"; From 98dcbd3e7eef4c35d48b75b2f3312096e078df9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:47 -0700 Subject: [PATCH 087/331] build: add setup entrypoints for migrated channel plugins --- extensions/line/package.json | 1 + extensions/mattermost/package.json | 1 + extensions/mattermost/setup-entry.ts | 5 +++++ extensions/nostr/package.json | 1 + extensions/nostr/setup-entry.ts | 5 +++++ extensions/zalo/package.json | 1 + extensions/zalo/setup-entry.ts | 5 +++++ extensions/zalouser/package.json | 1 + extensions/zalouser/setup-entry.ts | 5 +++++ 9 files changed, 25 insertions(+) create mode 100644 extensions/mattermost/setup-entry.ts create mode 100644 extensions/nostr/setup-entry.ts create mode 100644 extensions/zalo/setup-entry.ts create mode 100644 extensions/zalouser/setup-entry.ts diff --git a/extensions/line/package.json b/extensions/line/package.json index 85bfac7f0ac..3fa098460d6 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -8,6 +8,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "line", "label": "LINE", diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 17f8add1b1f..3c414f52f29 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "mattermost", "label": "Mattermost", diff --git a/extensions/mattermost/setup-entry.ts b/extensions/mattermost/setup-entry.ts new file mode 100644 index 00000000000..64c02fcbe9d --- /dev/null +++ b/extensions/mattermost/setup-entry.ts @@ -0,0 +1,5 @@ +import { mattermostPlugin } from "./src/channel.js"; + +export default { + plugin: mattermostPlugin, +}; diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 19ef7cc03e7..991bd54f3d4 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nostr", "label": "Nostr", diff --git a/extensions/nostr/setup-entry.ts b/extensions/nostr/setup-entry.ts new file mode 100644 index 00000000000..8884a71cc80 --- /dev/null +++ b/extensions/nostr/setup-entry.ts @@ -0,0 +1,5 @@ +import { nostrPlugin } from "./src/channel.js"; + +export default { + plugin: nostrPlugin, +}; diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index a72aabbb29e..b6ab61f7cee 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalo", "label": "Zalo", diff --git a/extensions/zalo/setup-entry.ts b/extensions/zalo/setup-entry.ts new file mode 100644 index 00000000000..dd8ca1b70f8 --- /dev/null +++ b/extensions/zalo/setup-entry.ts @@ -0,0 +1,5 @@ +import { zaloPlugin } from "./src/channel.js"; + +export default { + plugin: zaloPlugin, +}; diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index e7c12c9b4b2..5e3a1070237 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -12,6 +12,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalouser", "label": "Zalo Personal", diff --git a/extensions/zalouser/setup-entry.ts b/extensions/zalouser/setup-entry.ts new file mode 100644 index 00000000000..f983cad8f80 --- /dev/null +++ b/extensions/zalouser/setup-entry.ts @@ -0,0 +1,5 @@ +import { zalouserPlugin } from "./src/channel.js"; + +export default { + plugin: zalouserPlugin, +}; From dfc237c319788702fb826c48ea0273f5f4ab403d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:56 -0700 Subject: [PATCH 088/331] docs: update channel setup docs --- docs/channels/synology-chat.md | 6 +++++- docs/tools/plugin.md | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 89e96b318a3..aae655f27b7 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -27,13 +27,17 @@ Details: [Plugins](/tools/plugin) ## Quick setup 1. Install and enable the Synology Chat plugin. + - `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`. + - Non-interactive setup: `openclaw channels add --channel synology-chat --token --url ` 2. In Synology Chat integrations: - Create an incoming webhook and copy its URL. - Create an outgoing webhook with your secret token. 3. Point the outgoing webhook URL to your OpenClaw gateway: - `https://gateway-host/webhook/synology` by default. - Or your custom `channels.synology-chat.webhookPath`. -4. Configure `channels.synology-chat` in OpenClaw. +4. Finish setup in OpenClaw. + - Guided: `openclaw onboard` + - Direct: `openclaw channels add --channel synology-chat --token --url ` 5. Restart gateway and send a DM to the Synology Chat bot. Minimal config: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 976c10d0671..c39401bebfc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -776,7 +776,7 @@ Security note: `openclaw plugins install` installs plugin dependencies with trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. -When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or +When OpenClaw needs setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but still unconfigured, it loads `setupEntry` instead of the full plugin entry. This keeps startup and onboarding lighter when your main plugin entry also wires tools, hooks, or other runtime-only @@ -784,7 +784,7 @@ code. ### Channel catalog metadata -Channel plugins can advertise onboarding metadata via `openclaw.channel` and +Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and install hints via `openclaw.install`. This keeps the core catalog data-free. Example: @@ -1671,7 +1671,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. -- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. From 0eaf03f55bbf068e1d9b158c5345431df481c524 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:46:29 -0700 Subject: [PATCH 089/331] fix: update feishu setup adapter import --- extensions/feishu/src/onboarding.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index ff8f563cf65..ae247b30f76 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -1,7 +1,7 @@ -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { feishuPlugin } from "./channel.js"; -export const feishuOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +export const feishuOnboardingAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); From 92d53070744feaa0db14fc642cd751591d7178ae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:49:16 -0700 Subject: [PATCH 090/331] Status: lazy-load channel summary helpers --- src/commands/status.summary.test.ts | 19 +++++++++++++++++ src/commands/status.summary.ts | 33 ++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index addda823a23..c0344065126 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: vi.fn(() => true), +})); + vi.mock("../agents/context.js", () => ({ resolveContextTokensForModel: vi.fn(() => 200_000), })); @@ -82,4 +86,19 @@ describe("getStatusSummary", () => { expect(summary.heartbeat.defaultAgentId).toBe("main"); expect(summary.channelSummary).toEqual(["ok"]); }); + + it("skips channel summary imports when no channels are configured", async () => { + const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); + vi.mocked(hasPotentialConfiguredChannels).mockReturnValue(false); + const { buildChannelSummary } = await import("../infra/channel-summary.js"); + const { resolveLinkChannelContext } = await import("./status.link-channel.js"); + const { getStatusSummary } = await import("./status.summary.js"); + + const summary = await getStatusSummary(); + + expect(summary.channelSummary).toEqual([]); + expect(summary.linkChannel).toBeUndefined(); + expect(buildChannelSummary).not.toHaveBeenCalled(); + expect(resolveLinkChannelContext).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index e1347a90b5a..b028c99ab6d 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -16,14 +16,25 @@ import { listAgentsForGateway, resolveSessionModelRef, } from "../gateway/session-utils.js"; -import { buildChannelSummary } from "../infra/channel-summary.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveRuntimeServiceVersion } from "../version.js"; -import { resolveLinkChannelContext } from "./status.link-channel.js"; import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; +let channelSummaryModulePromise: Promise | undefined; +let linkChannelModulePromise: Promise | undefined; + +function loadChannelSummaryModule() { + channelSummaryModulePromise ??= import("../infra/channel-summary.js"); + return channelSummaryModulePromise; +} + +function loadLinkChannelModule() { + linkChannelModulePromise ??= import("./status.link-channel.js"); + return linkChannelModulePromise; +} + const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -91,7 +102,11 @@ export async function getStatusSummary( const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); - const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null; + const linkContext = needsChannelPlugins + ? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) => + resolveLinkChannelContext(cfg), + ) + : null; const agentList = listAgentsForGateway(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); @@ -103,11 +118,13 @@ export async function getStatusSummary( } satisfies HeartbeatStatus; }); const channelSummary = needsChannelPlugins - ? await buildChannelSummary(cfg, { - colorize: true, - includeAllowFrom: true, - sourceConfig: options.sourceConfig, - }) + ? await loadChannelSummaryModule().then(({ buildChannelSummary }) => + buildChannelSummary(cfg, { + colorize: true, + includeAllowFrom: true, + sourceConfig: options.sourceConfig, + }), + ) : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); From 1f50fed3b28bf4a87439152c6f2dd50bedcc3db5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:52:09 -0700 Subject: [PATCH 091/331] Agents: skip eager context warmup for status commands --- src/agents/context.lookup.test.ts | 28 ++++++++++++++++++++++++++++ src/agents/context.ts | 2 ++ 2 files changed, 30 insertions(+) diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index e5025b36c76..0f33ada0d1b 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -104,6 +104,34 @@ describe("lookupContextTokens", () => { } }); + it("skips eager warmup for status commands that only read model metadata opportunistically", async () => { + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + + const argvSnapshot = process.argv; + process.argv = ["node", "openclaw", "status", "--json"]; + try { + await import("./context.js"); + expect(loadConfigMock).not.toHaveBeenCalled(); + } finally { + process.argv = argvSnapshot; + } + }); + + it("skips eager warmup for gateway commands that do not need model metadata at startup", async () => { + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + + const argvSnapshot = process.argv; + process.argv = ["node", "openclaw", "gateway", "status", "--json"]; + try { + await import("./context.js"); + expect(loadConfigMock).not.toHaveBeenCalled(); + } finally { + process.argv = argvSnapshot; + } + }); + it("retries config loading after backoff when an initial load fails", async () => { vi.useFakeTimers(); const loadConfigMock = vi diff --git a/src/agents/context.ts b/src/agents/context.ts index 5550f67e3b7..cfeee26cd60 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -114,11 +114,13 @@ const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([ "config", "directory", "doctor", + "gateway", "health", "hooks", "logs", "plugins", "secrets", + "status", "update", "webhooks", ]); From ca2f0466686d5ff39ef75d1e77d0c88f07ca5383 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:56:44 -0700 Subject: [PATCH 092/331] Status: route JSON through lean command --- src/cli/program/routes.test.ts | 25 +++++++++ src/cli/program/routes.ts | 5 ++ src/commands/status-json.ts | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/commands/status-json.ts diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 896dcb6757a..65cba06e299 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -6,6 +6,7 @@ const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -21,6 +22,10 @@ vi.mock("../../commands/gateway-status.js", () => ({ gatewayStatusCommand: gatewayStatusCommandMock, })); +vi.mock("../../commands/status-json.js", () => ({ + statusJsonCommand: statusJsonCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,6 +129,26 @@ describe("program routes", () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); + it("routes status --json through the lean JSON command", async () => { + const route = expectRoute(["status"]); + await expect( + route?.run([ + "node", + "openclaw", + "status", + "--json", + "--deep", + "--usage", + "--timeout", + "5000", + ]), + ).resolves.toBe(true); + expect(statusJsonCommandMock).toHaveBeenCalledWith( + { deep: true, all: false, usage: true, timeoutMs: 5000 }, + expect.any(Object), + ); + }); + it("returns false for sessions route when --store value is missing", async () => { await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--store"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 353c9b8f11d..913f84dd2e4 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -47,6 +47,11 @@ const routeStatus: RouteSpec = { if (timeoutMs === null) { return false; } + if (json) { + const { statusJsonCommand } = await import("../../commands/status-json.js"); + await statusJsonCommand({ deep, all, usage, timeoutMs }, defaultRuntime); + return true; + } const { statusCommand } = await import("../../commands/status.js"); await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); return true; diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts new file mode 100644 index 00000000000..035f2c71245 --- /dev/null +++ b/src/commands/status-json.ts @@ -0,0 +1,100 @@ +import { callGateway } from "../gateway/call.js"; +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { runSecurityAudit } from "../security/audit.js"; +import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; +import { scanStatus } from "./status.scan.js"; + +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + +export async function statusJsonCommand( + opts: { + deep?: boolean; + usage?: boolean; + timeoutMs?: number; + all?: boolean; + }, + runtime: RuntimeEnv, +) { + const scan = await scanStatus({ json: true, timeoutMs: opts.timeoutMs, all: opts.all }, runtime); + const securityAudit = await runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + + const usage = opts.usage + ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => + loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + ) + : undefined; + const health = opts.deep + ? await callGateway({ + method: "health", + params: { probe: true }, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => undefined) + : undefined; + const lastHeartbeat = + opts.deep && scan.gatewayReachable + ? await callGateway({ + method: "last-heartbeat", + params: {}, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => null) + : null; + + const [daemon, nodeDaemon] = await Promise.all([ + getDaemonStatusSummary(), + getNodeDaemonStatusSummary(), + ]); + const channelInfo = resolveUpdateChannelDisplay({ + configChannel: normalizeUpdateChannel(scan.cfg.update?.channel), + installKind: scan.update.installKind, + gitTag: scan.update.git?.tag ?? null, + gitBranch: scan.update.git?.branch ?? null, + }); + + runtime.log( + JSON.stringify( + { + ...scan.summary, + os: scan.osSummary, + update: scan.update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory: scan.memory, + memoryPlugin: scan.memoryPlugin, + gateway: { + mode: scan.gatewayMode, + url: scan.gatewayConnection.url, + urlSource: scan.gatewayConnection.urlSource, + misconfigured: scan.remoteUrlMissing, + reachable: scan.gatewayReachable, + connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, + self: scan.gatewaySelf, + error: scan.gatewayProbe?.error ?? null, + authWarning: scan.gatewayProbeAuthWarning ?? null, + }, + gatewayService: daemon, + nodeService: nodeDaemon, + agents: scan.agentStatus, + securityAudit, + secretDiagnostics: scan.secretDiagnostics, + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), + }, + null, + 2, + ), + ); +} From a33caab280f3e289005e4d37bc6449208a0d3d8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:58:59 -0700 Subject: [PATCH 093/331] refactor(plugins): move auth and model policy to providers --- docs/concepts/model-providers.md | 21 +- docs/tools/plugin.md | 56 +- extensions/anthropic/index.ts | 74 ++- extensions/github-copilot/index.ts | 3 + extensions/google/gemini-cli-provider.test.ts | 67 ++- extensions/google/gemini-cli-provider.ts | 58 +- extensions/google/index.ts | 11 + extensions/google/openclaw.plugin.json | 5 +- extensions/google/provider-models.ts | 63 ++ extensions/minimax/index.ts | 6 + extensions/openai/openai-codex-provider.ts | 63 +- extensions/openai/openai-provider.ts | 11 +- extensions/openai/shared.ts | 8 + extensions/opencode-go/index.ts | 1 + extensions/opencode/index.ts | 10 + extensions/openrouter/index.ts | 1 + extensions/zai/index.ts | 10 + src/agents/live-model-filter.ts | 15 + src/agents/model-compat.test.ts | 136 +---- src/agents/model-forward-compat.ts | 123 ---- src/agents/pi-embedded-runner/model.ts | 50 -- src/auto-reply/thinking.test.ts | 43 +- src/auto-reply/thinking.ts | 60 +- src/commands/models/auth.test.ts | 139 +++-- src/commands/models/auth.ts | 538 ++++++++++-------- src/plugin-sdk/core.ts | 3 + src/plugin-sdk/index.ts | 3 + src/plugins/provider-runtime.test.ts | 49 ++ src/plugins/provider-runtime.ts | 43 ++ src/plugins/types.ts | 63 ++ 30 files changed, 1080 insertions(+), 653 deletions(-) create mode 100644 extensions/google/provider-models.ts delete mode 100644 src/agents/model-forward-compat.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index aa4b90fd41f..eb0f8a1c6a2 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -25,8 +25,10 @@ For model selection rules, see [/concepts/models](/concepts/models). `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, - `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, - `resolveUsageAuth`, and `fetchUsageSnapshot`. + `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, + `supportsXHighThinking`, `resolveDefaultThinkingLevel`, + `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, and + `fetchUsageSnapshot`. ## Plugin-owned provider behavior @@ -51,6 +53,11 @@ Typical split: vendor-owned error for direct resolution failures - `augmentModelCatalog`: provider appends synthetic/final catalog rows after discovery and config merging +- `isBinaryThinking`: provider owns binary on/off thinking UX +- `supportsXHighThinking`: provider opts selected models into `xhigh` +- `resolveDefaultThinkingLevel`: provider owns default `/think` policy for a + model family +- `isModernModelRef`: provider owns live/smoke preferred-model matching - `prepareRuntimeAuth`: provider turns a configured credential into a short lived runtime token - `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage` @@ -68,14 +75,16 @@ Current bundled examples: hints, runtime token exchange, and usage endpoint fetching - `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport normalization, Codex-aware missing-auth hints, Spark suppression, synthetic - OpenAI/Codex catalog rows, and provider-family metadata -- `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token - parsing and quota endpoint fetching for usage surfaces + OpenAI/Codex catalog rows, thinking/live-model policy, and + provider-family metadata +- `google` and `google-gemini-cli`: Gemini 3.1 forward-compat fallback and + modern-model matching; Gemini CLI OAuth also owns usage-token parsing and + quota endpoint fetching for usage surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization - `kilocode`: shared transport, plugin-owned request headers, reasoning payload normalization, Gemini transcript hints, and cache-TTL policy - `zai`: GLM-5 forward-compat fallback, `tool_stream` defaults, cache-TTL - policy, and usage auth + quota fetching + policy, binary-thinking/live-model policy, and usage auth + quota fetching - `mistral`, `opencode`, and `opencode-go`: plugin-owned capability metadata - `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c39401bebfc..62350fb9dd4 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -220,7 +220,7 @@ Provider plugins now have two layers: - manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before runtime load - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -263,13 +263,22 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: error hint. 12. `augmentModelCatalog` Provider-owned synthetic/final catalog rows appended after discovery. -13. `prepareRuntimeAuth` +13. `isBinaryThinking` + Provider-owned on/off reasoning toggle for binary-thinking providers. +14. `supportsXHighThinking` + Provider-owned `xhigh` reasoning support for selected models. +15. `resolveDefaultThinkingLevel` + Provider-owned default `/think` level for a specific model family. +16. `isModernModelRef` + Provider-owned modern-model matcher used by live profile filters and smoke + selection. +17. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. -14. `resolveUsageAuth` +18. `resolveUsageAuth` Resolves usage/billing credentials for `/usage` and related status surfaces. -15. `fetchUsageSnapshot` +19. `fetchUsageSnapshot` Fetches and normalizes provider-specific usage/quota snapshots after auth is resolved. @@ -286,6 +295,10 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint - `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures - `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging +- `isBinaryThinking`: expose binary on/off reasoning UX without hardcoding provider ids in `/think` +- `supportsXHighThinking`: opt specific models into the `xhigh` reasoning level +- `resolveDefaultThinkingLevel`: keep provider/model default reasoning policy out of core +- `isModernModelRef`: keep live/smoke model family inclusion rules with the provider - `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests - `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core - `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting @@ -303,6 +316,10 @@ Rule of thumb: - provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage` - provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel` - provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog` +- provider exposes only binary thinking on/off: use `isBinaryThinking` +- provider wants `xhigh` on only a subset of models: use `supportsXHighThinking` +- provider owns default `/think` policy for a model family: use `resolveDefaultThinkingLevel` +- provider owns live/smoke preferred-model matching: use `isModernModelRef` - provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` - provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth` - provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot` @@ -368,14 +385,17 @@ api.registerProvider({ ### Built-in examples - Anthropic uses `resolveDynamicModel`, `capabilities`, `resolveUsageAuth`, - `fetchUsageSnapshot`, and `isCacheTtlEligible` because it owns Claude 4.6 - forward-compat, provider-family hints, usage endpoint integration, and - prompt-cache eligibility. + `fetchUsageSnapshot`, `isCacheTtlEligible`, `resolveDefaultThinkingLevel`, + and `isModernModelRef` because it owns Claude 4.6 forward-compat, + provider-family hints, usage endpoint integration, prompt-cache + eligibility, and Claude default/adaptive thinking policy. - OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and - `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, and - `augmentModelCatalog` because it owns GPT-5.4 forward-compat, the direct - OpenAI `openai-completions` -> `openai-responses` normalization, Codex-aware - auth hints, Spark suppression, and synthetic OpenAI list rows. + `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, + `augmentModelCatalog`, `supportsXHighThinking`, and `isModernModelRef` + because it owns GPT-5.4 forward-compat, the direct OpenAI + `openai-completions` -> `openai-responses` normalization, Codex-aware auth + hints, Spark suppression, synthetic OpenAI list rows, and GPT-5 thinking / + live-model policy. - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. @@ -389,9 +409,10 @@ api.registerProvider({ still runs on core OpenAI transports but owns its transport/base URL normalization, default transport choice, synthetic Codex catalog rows, and ChatGPT usage endpoint integration. -- Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and - `fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus - the token parsing and quota endpoint wiring needed by `/usage`. +- Google AI Studio and Gemini CLI OAuth use `resolveDynamicModel` and + `isModernModelRef` because they own Gemini 3.1 forward-compat fallback and + modern-model matching; Gemini CLI OAuth also uses `resolveUsageAuth` and + `fetchUsageSnapshot` for token parsing and quota endpoint wiring. - OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. @@ -402,9 +423,10 @@ api.registerProvider({ reasoning payload normalization, Gemini transcript hints, and Anthropic cache-TTL gating. - Z.AI uses `resolveDynamicModel`, `prepareExtraParams`, `wrapStreamFn`, - `isCacheTtlEligible`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it - owns GLM-5 fallback, `tool_stream` defaults, and both usage auth + quota - fetching. + `isCacheTtlEligible`, `isBinaryThinking`, `isModernModelRef`, + `resolveUsageAuth`, and `fetchUsageSnapshot` because it owns GLM-5 fallback, + `tool_stream` defaults, binary thinking UX, modern-model matching, and both + usage auth + quota fetching. - Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep transcript/tooling quirks out of core. - Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`, diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index bb17f9d4dc1..5ea7e20b6d9 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -1,11 +1,14 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi, + type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; +import type { ProviderAuthResult } from "../../src/plugins/types.js"; const PROVIDER_ID = "anthropic"; const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; @@ -14,6 +17,13 @@ const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6"; const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6"; const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const; +const ANTHROPIC_MODERN_MODEL_PREFIXES = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", +] as const; function cloneFirstTemplateModel(params: { modelId: string; @@ -96,6 +106,51 @@ function resolveAnthropicForwardCompatModel( ); } +function matchesAnthropicModernModel(modelId: string): boolean { + const lower = modelId.trim().toLowerCase(); + return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +async function runAnthropicSetupToken(ctx: ProviderAuthContext): Promise { + await ctx.prompter.note( + ["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join( + "\n", + ), + "Anthropic setup-token", + ); + + const tokenRaw = await ctx.prompter.text({ + message: "Paste Anthropic setup-token", + validate: (value) => validateAnthropicSetupToken(String(value ?? "")), + }); + const token = String(tokenRaw ?? "").trim(); + const tokenError = validateAnthropicSetupToken(token); + if (tokenError) { + throw new Error(tokenError); + } + + const profileNameRaw = await ctx.prompter.text({ + message: "Token name (blank = default)", + placeholder: "default", + }); + + return { + profiles: [ + { + profileId: buildTokenProfileId({ + provider: PROVIDER_ID, + name: String(profileNameRaw ?? ""), + }), + credential: { + type: "token", + provider: PROVIDER_ID, + token, + }, + }, + ], + }; +} + const anthropicPlugin = { id: PROVIDER_ID, name: "Anthropic Provider", @@ -107,12 +162,29 @@ const anthropicPlugin = { label: "Anthropic", docsPath: "/providers/models", envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - auth: [], + auth: [ + { + id: "setup-token", + label: "setup-token (claude)", + hint: "Paste a setup-token from `claude setup-token`", + kind: "token", + run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), + }, + ], resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), capabilities: { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, + isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), + resolveDefaultThinkingLevel: ({ modelId }) => + matchesAnthropicModernModel(modelId) && + (modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID)) + ? "adaptive" + : undefined, resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 038ed70aec9..41c9deed5ec 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -15,6 +15,7 @@ const PROVIDER_ID = "github-copilot"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; const CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; +const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.ProcessEnv }): { githubToken: string; @@ -117,6 +118,8 @@ const githubCopilotPlugin = { capabilities: { dropThinkingBlockModelHints: ["claude"], }, + supportsXHighThinking: ({ modelId }) => + COPILOT_XHIGH_MODEL_IDS.includes(modelId.trim().toLowerCase() as never), prepareRuntimeAuth: async (ctx) => { const token = await resolveCopilotApiToken({ githubToken: ctx.apiKey, diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index 341ecd9e0b9..21e7f505521 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -7,8 +7,16 @@ import { } from "../../src/test-utils/provider-usage-fetch.js"; import googlePlugin from "./index.js"; +function findProvider(providers: ProviderPlugin[], id: string): ProviderPlugin { + const provider = providers.find((candidate) => candidate.id === id); + if (!provider) { + throw new Error(`provider ${id} missing`); + } + return provider; +} + function registerGooglePlugin(): { - provider: ProviderPlugin; + providers: ProviderPlugin[]; webSearchProvider: { id: string; envVars: string[]; @@ -18,13 +26,12 @@ function registerGooglePlugin(): { } { const captured = createCapturedPluginRegistration(); googlePlugin.register(captured.api); - const provider = captured.providers[0]; - if (!provider) { + if (captured.providers.length === 0) { throw new Error("provider registration missing"); } const webSearchProvider = captured.webSearchProviders[0] ?? null; return { - provider, + providers: captured.providers, webSearchProviderRegistered: webSearchProvider !== null, webSearchProvider: webSearchProvider === null @@ -38,10 +45,13 @@ function registerGooglePlugin(): { } describe("google plugin", () => { - it("registers both Gemini CLI auth and Gemini web search", () => { + it("registers Google direct, Gemini CLI auth, and Gemini web search", () => { const result = registerGooglePlugin(); - expect(result.provider.id).toBe("google-gemini-cli"); + expect(result.providers.map((provider) => provider.id)).toEqual([ + "google", + "google-gemini-cli", + ]); expect(result.webSearchProviderRegistered).toBe(true); expect(result.webSearchProvider).toMatchObject({ id: "gemini", @@ -50,8 +60,43 @@ describe("google plugin", () => { }); }); - it("owns gemini 3.1 forward-compat resolution", () => { - const { provider } = registerGooglePlugin(); + it("owns google direct gemini 3.1 forward-compat resolution", () => { + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google"); + const model = provider.resolveDynamicModel?.({ + provider: "google", + modelId: "gemini-3.1-pro-preview", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gemini-3-pro-preview" + ? { + id, + name: id, + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 65_536, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gemini-3.1-pro-preview", + provider: "google", + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: true, + }); + }); + + it("owns gemini cli 3.1 forward-compat resolution", () => { + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); const model = provider.resolveDynamicModel?.({ provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", @@ -82,7 +127,8 @@ describe("google plugin", () => { }); it("owns usage-token parsing", async () => { - const { provider } = registerGooglePlugin(); + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -101,7 +147,8 @@ describe("google plugin", () => { }); it("owns usage snapshot fetching", async () => { - const { provider } = registerGooglePlugin(); + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { return makeResponse(200, { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index b4bb58f7d80..5a3d784a866 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,22 +1,16 @@ -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { OpenClawPluginApi, ProviderAuthContext, ProviderFetchUsageSnapshotContext, - ProviderResolveDynamicModelContext, - ProviderRuntimeModel, } from "../../src/plugins/types.js"; import { loginGeminiCliOAuth } from "./oauth.js"; +import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const PROVIDER_ID = "google-gemini-cli"; const PROVIDER_LABEL = "Gemini CLI OAuth"; const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; const ENV_VARS = [ "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", @@ -24,30 +18,6 @@ const ENV_VARS = [ "GEMINI_CLI_OAUTH_CLIENT_SECRET", ]; -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - reasoning: true, - } as ProviderRuntimeModel); - } - return undefined; -} - function parseGoogleUsageToken(apiKey: string): string { try { const parsed = JSON.parse(apiKey) as { token?: unknown }; @@ -64,28 +34,6 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } -function resolveGeminiCliForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmed = ctx.modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - modelId: trimmed, - templateIds, - ctx, - }); -} - export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -133,7 +81,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }, }, ], - resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), + resolveDynamicModel: (ctx) => + resolveGoogle31ForwardCompatModel({ providerId: PROVIDER_ID, ctx }), + isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), resolveUsageAuth: async (ctx) => { const auth = await ctx.resolveOAuthToken(); if (!auth) { diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 806133b6419..0afa07e2ce0 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -6,6 +6,7 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const googlePlugin = { id: "google", @@ -13,6 +14,16 @@ const googlePlugin = { description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { + api.registerProvider({ + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => + resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }), + isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), + }); registerGoogleGeminiCliProvider(api); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 1a6d0dcd196..0d64bb18c14 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "google", - "providers": ["google-gemini-cli"], + "providers": ["google", "google-gemini-cli"], + "providerAuthEnvVars": { + "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts new file mode 100644 index 00000000000..0a086780b1a --- /dev/null +++ b/extensions/google/provider-models.ts @@ -0,0 +1,63 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; + +const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; +const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; +const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; +const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; + +function cloneFirstTemplateModel(params: { + providerId: string; + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + return undefined; +} + +export function resolveGoogle31ForwardCompatModel(params: { + providerId: string; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmed = params.ctx.modelId.trim(); + const lower = trimmed.toLowerCase(); + + let templateIds: readonly string[]; + if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { + templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; + } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { + templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; + } else { + return undefined; + } + + return cloneFirstTemplateModel({ + providerId: params.providerId, + modelId: trimmed, + templateIds, + ctx: params.ctx, + }); +} + +export function isModernGoogleModel(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith("gemini-3"); +} diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e99f5bf15b2..0231fd86236 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -30,6 +30,10 @@ function modelRef(modelId: string): string { return `${PORTAL_PROVIDER_ID}/${modelId}`; } +function isModernMiniMaxModel(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith("minimax-m2.5"); +} + function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { return { ...buildMinimaxPortalProvider(), @@ -167,6 +171,7 @@ const minimaxPlugin = { }); return apiKey ? { token: apiKey } : null; }, + isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), fetchUsageSnapshot: async (ctx) => await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); @@ -195,6 +200,7 @@ const minimaxPlugin = { run: createOAuthHandler("cn"), }, ], + isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); }, }; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index af5f85d4d21..68058170f19 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -1,4 +1,5 @@ import type { + ProviderAuthContext, ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; @@ -8,9 +9,16 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; +import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; -import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; +import { + cloneFirstTemplateModel, + findCatalogTemplate, + isOpenAIApiBaseUrl, + matchesExactOrPrefix, +} from "./shared.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; @@ -23,6 +31,24 @@ const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; +const OPENAI_CODEX_DEFAULT_MODEL = `${PROVIDER_ID}/${OPENAI_CODEX_GPT_54_MODEL_ID}`; +const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + OPENAI_CODEX_GPT_54_MODEL_ID, + OPENAI_CODEX_GPT_53_MODEL_ID, + OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; +const OPENAI_CODEX_MODERN_MODEL_IDS = [ + OPENAI_CODEX_GPT_54_MODEL_ID, + "gpt-5.2", + "gpt-5.2-codex", + OPENAI_CODEX_GPT_53_MODEL_ID, + OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", +] as const; function isOpenAICodexBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); @@ -106,12 +132,42 @@ function resolveCodexForwardCompatModel( ); } +async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { + const creds = await loginOpenAICodexOAuth({ + prompter: ctx.prompter, + runtime: ctx.runtime, + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + localBrowserMessage: "Complete sign-in in browser…", + }); + if (!creds) { + throw new Error("OpenAI Codex OAuth did not return credentials."); + } + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + email: typeof creds.email === "string" ? creds.email : undefined, + }); +} + export function buildOpenAICodexProviderPlugin(): ProviderPlugin { return { id: PROVIDER_ID, label: "OpenAI Codex", docsPath: "/providers/models", - auth: [], + auth: [ + { + id: "oauth", + label: "ChatGPT OAuth", + hint: "Browser sign-in", + kind: "oauth", + run: async (ctx) => await runOpenAICodexOAuth(ctx), + }, + ], catalog: { order: "profile", run: async (ctx) => { @@ -130,6 +186,9 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { capabilities: { providerFamily: "openai", }, + supportsXHighThinking: ({ modelId }) => + matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS), + isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS), prepareExtraParams: (ctx) => { const transport = ctx.extraParams?.transport; if (transport === "auto" || transport === "sse" || transport === "websocket") { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9ce61e2a2b8..be406f26bbb 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -5,7 +5,12 @@ import { import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; -import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; +import { + cloneFirstTemplateModel, + findCatalogTemplate, + isOpenAIApiBaseUrl, + matchesExactOrPrefix, +} from "./shared.js"; const PROVIDER_ID = "openai"; const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; @@ -14,6 +19,8 @@ const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MAX_TOKENS = 128_000; const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; +const OPENAI_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2"] as const; +const OPENAI_MODERN_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2", "gpt-5.0"] as const; const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); @@ -93,6 +100,8 @@ export function buildOpenAIProvider(): ProviderPlugin { capabilities: { providerFamily: "openai", }, + supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS), + isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS), buildMissingAuthMessage: (ctx) => { if (ctx.provider !== PROVIDER_ID || ctx.listProfileIds("openai-codex").length === 0) { return undefined; diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index c8654be2f9b..4e4c8c2d850 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -6,6 +6,14 @@ import type { export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; +export function matchesExactOrPrefix(id: string, values: readonly string[]): boolean { + const normalizedId = id.trim().toLowerCase(); + return values.some((value) => { + const normalizedValue = value.trim().toLowerCase(); + return normalizedId === normalizedValue || normalizedId.startsWith(normalizedValue); + }); +} + export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 3740c0190c4..87e52eab53e 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -19,6 +19,7 @@ const opencodeGoPlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: () => true, }); }, }; diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 81175fc5613..c800961ab36 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,6 +1,15 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; const PROVIDER_ID = "opencode"; +const MINIMAX_PREFIX = "minimax-m2.5"; + +function isModernOpencodeModel(modelId: string): boolean { + const lower = modelId.trim().toLowerCase(); + if (lower.endsWith("-free") || lower === "alpha-glm-4.7") { + return false; + } + return !lower.startsWith(MINIMAX_PREFIX); +} const opencodePlugin = { id: PROVIDER_ID, @@ -19,6 +28,7 @@ const opencodePlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId), }); }, }; diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index faa7b338cf1..92521cb3984 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -110,6 +110,7 @@ const openRouterPlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: () => true, wrapStreamFn: (ctx) => { let streamFn = ctx.streamFn; const providerRouting = diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index d9b81b87dda..f4fd60ad5c3 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -98,6 +98,16 @@ const zaiPlugin = { }, wrapStreamFn: (ctx) => createZaiToolStreamWrapper(ctx.streamFn, ctx.extraParams?.tool_stream !== false), + isBinaryThinking: () => true, + isModernModelRef: ({ modelId }) => { + const lower = modelId.trim().toLowerCase(); + return ( + lower.startsWith("glm-5") || + lower.startsWith("glm-4.7") || + lower.startsWith("glm-4.7-flash") || + lower.startsWith("glm-4.7-flashx") + ); + }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ providerIds: [PROVIDER_ID, "z-ai"], diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index 059e12d9711..e047d70dbde 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -1,3 +1,5 @@ +import { resolveProviderModernModelRef } from "../plugins/provider-runtime.js"; + export type ModelRef = { provider?: string | null; id?: string | null; @@ -41,6 +43,19 @@ export function isModernModelRef(ref: ModelRef): boolean { return false; } + const pluginDecision = resolveProviderModernModelRef({ + provider, + context: { + provider, + modelId: id, + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + + // Compatibility fallback for core-owned providers and tests that disable + // bundled provider runtime hooks. if (provider === "anthropic") { return matchesPrefix(id, ANTHROPIC_PREFIXES); } diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 9bb1bf76eff..c473aadf8e6 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -1,9 +1,16 @@ import type { Api, Model } from "@mariozechner/pi-ai"; -import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const providerRuntimeMocks = vi.hoisted(() => ({ + resolveProviderModernModelRef: vi.fn(), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderModernModelRef: providerRuntimeMocks.resolveProviderModernModelRef, +})); + import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; -import { resolveForwardCompatModel } from "./model-forward-compat.js"; const baseModel = (): Model => ({ @@ -32,43 +39,6 @@ function supportsStrictMode(model: Model): boolean | undefined { return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode; } -function createTemplateModel(provider: string, id: string): Model { - return { - id, - name: id, - provider, - api: "anthropic-messages", - input: ["text"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - } as Model; -} - -function createOpenAITemplateModel(id: string): Model { - return { - id, - name: id, - provider: "openai", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - input: ["text", "image"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400_000, - maxTokens: 32_768, - } as Model; -} - -function createRegistry(models: Record>): ModelRegistry { - return { - find(provider: string, modelId: string) { - return models[`${provider}/${modelId}`] ?? null; - }, - } as ModelRegistry; -} - function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; @@ -90,14 +60,10 @@ function expectSupportsStrictModeForcedOff(overrides?: Partial>): voi expect(supportsStrictMode(normalized)).toBe(false); } -function expectResolvedForwardCompat( - model: Model | undefined, - expected: { provider: string; id: string }, -): void { - expect(model?.id).toBe(expected.id); - expect(model?.name).toBe(expected.id); - expect(model?.provider).toBe(expected.provider); -} +beforeEach(() => { + providerRuntimeMocks.resolveProviderModernModelRef.mockReset(); + providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined); +}); describe("normalizeModelCompat — Anthropic baseUrl", () => { const anthropicBase = (): Model => @@ -373,6 +339,12 @@ describe("normalizeModelCompat", () => { }); describe("isModernModelRef", () => { + it("uses provider runtime hooks before fallback heuristics", () => { + providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(false); + + expect(isModernModelRef({ provider: "openrouter", id: "claude-opus-4-6" })).toBe(false); + }); + it("includes OpenAI gpt-5.4 variants in modern selection", () => { expect(isModernModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-pro" })).toBe(true); @@ -395,71 +367,3 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true); }); }); - -describe("resolveForwardCompatModel", () => { - it("resolves openai gpt-5.4 via gpt-5.2 template", () => { - const registry = createRegistry({ - "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), - }); - const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - }); - - it("resolves openai gpt-5.4 without templates using normalized fallback defaults", () => { - const registry = createRegistry({}); - - const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); - - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.input).toEqual(["text", "image"]); - expect(model?.reasoning).toBe(true); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - expect(model?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); - }); - - it("resolves openai gpt-5.4-pro via template fallback", () => { - const registry = createRegistry({ - "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), - }); - const model = resolveForwardCompatModel("openai", "gpt-5.4-pro", registry); - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4-pro" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - }); - - it("resolves anthropic opus 4.6 via 4.5 template", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); - expectResolvedForwardCompat(model, { provider: "anthropic", id: "claude-opus-4-6" }); - }); - - it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { - const registry = createRegistry({ - "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( - "anthropic", - "claude-sonnet-4.5-20260219", - ), - }); - const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); - expectResolvedForwardCompat(model, { provider: "anthropic", id: "claude-sonnet-4.6-20260219" }); - }); - - it("does not resolve anthropic 4.6 fallback for other providers", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); - expect(model).toBeUndefined(); - }); -}); diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts deleted file mode 100644 index 5319d30423e..00000000000 --- a/src/agents/model-forward-compat.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; -import { normalizeModelCompat } from "./model-compat.js"; -import { normalizeProviderId } from "./model-selection.js"; - -const ZAI_GLM5_MODEL_ID = "glm-5"; -const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; - -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai -// Google catalogs yet. Clone the nearest gemini-3 template so users don't get -// "Unknown model" errors when Google ships new minor-version models before pi-ai -// updates its built-in registry. -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; - -function cloneFirstTemplateModel(params: { - normalizedProvider: string; - trimmedModelId: string; - templateIds: string[]; - modelRegistry: ModelRegistry; - patch?: Partial>; -}): Model | undefined { - const { normalizedProvider, trimmedModelId, templateIds, modelRegistry } = params; - for (const templateId of [...new Set(templateIds)].filter(Boolean)) { - const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as Model); - } - return undefined; -} - -function resolveGoogle31ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "google" && normalizedProvider !== "google-gemini-cli") { - return undefined; - } - const trimmed = modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId: trimmed, - templateIds: [...templateIds], - modelRegistry, - patch: { reasoning: true }, - }); -} - -// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet. -// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback. -function resolveZaiGlm5ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - if (normalizeProviderId(provider) !== "zai") { - return undefined; - } - const trimmed = modelId.trim(); - const lower = trimmed.toLowerCase(); - if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) { - return undefined; - } - - for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) { - const template = modelRegistry.find("zai", templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmed, - name: trimmed, - reasoning: true, - } as Model); - } - - return normalizeModelCompat({ - id: trimmed, - name: trimmed, - api: "openai-completions", - provider: "zai", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - maxTokens: DEFAULT_CONTEXT_TOKENS, - } as Model); -} - -export function resolveForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return ( - resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry) - ); -} diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index ed6356a361f..5bf97a683d0 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -13,7 +13,6 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; -import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; import { buildSuppressedBuiltInModelError, @@ -34,8 +33,6 @@ type InlineProviderConfig = { headers?: unknown; }; -const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); - function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -232,53 +229,6 @@ function resolveExplicitModelWithRegistry(params: { }; } - if (PLUGIN_FIRST_DYNAMIC_PROVIDERS.has(normalizeProviderId(provider))) { - // Give migrated provider plugins first shot at ids that still keep a core - // forward-compat fallback for disabled-plugin/test compatibility. - const pluginDynamicModel = runProviderDynamicModel({ - provider, - config: cfg, - context: { - config: cfg, - agentDir, - provider, - modelId, - modelRegistry, - providerConfig, - }, - }); - if (pluginDynamicModel) { - return { - kind: "resolved", - model: normalizeResolvedModel({ - provider, - cfg, - agentDir, - model: pluginDynamicModel, - }), - }; - } - } - - // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. - // Otherwise, configured providers can default to a generic API and break specific transports. - const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); - if (forwardCompat) { - return { - kind: "resolved", - model: normalizeResolvedModel({ - provider, - cfg, - agentDir, - model: applyConfiguredProviderOverrides({ - discoveredModel: forwardCompat, - providerConfig, - modelId, - }), - }), - }; - } - return undefined; } diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index d4814a263e9..48113b3ce72 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const providerRuntimeMocks = vi.hoisted(() => ({ + resolveProviderBinaryThinking: vi.fn(), + resolveProviderDefaultThinkingLevel: vi.fn(), + resolveProviderXHighThinking: vi.fn(), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking, + resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel, + resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking, +})); import { listThinkingLevelLabels, listThinkingLevels, @@ -7,6 +19,15 @@ import { resolveThinkingDefaultForModel, } from "./thinking.js"; +beforeEach(() => { + providerRuntimeMocks.resolveProviderBinaryThinking.mockReset(); + providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(undefined); + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReset(); + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined); + providerRuntimeMocks.resolveProviderXHighThinking.mockReset(); + providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(undefined); +}); + describe("normalizeThinkLevel", () => { it("accepts mid as medium", () => { expect(normalizeThinkLevel("mid")).toBe("medium"); @@ -43,6 +64,12 @@ describe("normalizeThinkLevel", () => { }); describe("listThinkingLevels", () => { + it("uses provider runtime hooks for xhigh support", () => { + providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(true); + + expect(listThinkingLevels("demo", "demo-model")).toContain("xhigh"); + }); + it("includes xhigh for codex models", () => { expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh"); expect(listThinkingLevels(undefined, "gpt-5.3-codex")).toContain("xhigh"); @@ -75,6 +102,12 @@ describe("listThinkingLevels", () => { }); describe("listThinkingLevelLabels", () => { + it("uses provider runtime hooks for binary thinking providers", () => { + providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(true); + + expect(listThinkingLevelLabels("demo", "demo-model")).toEqual(["off", "on"]); + }); + it("returns on/off for ZAI", () => { expect(listThinkingLevelLabels("zai", "glm-4.7")).toEqual(["off", "on"]); }); @@ -86,6 +119,14 @@ describe("listThinkingLevelLabels", () => { }); describe("resolveThinkingDefaultForModel", () => { + it("uses provider runtime hooks for default thinking levels", () => { + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue("adaptive"); + + expect(resolveThinkingDefaultForModel({ provider: "demo", model: "demo-model" })).toBe( + "adaptive", + ); + }); + it("defaults Claude 4.6 models to adaptive", () => { expect( resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }), diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 639db68eafb..9c03086ab91 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,3 +1,9 @@ +import { + resolveProviderBinaryThinking, + resolveProviderDefaultThinkingLevel, + resolveProviderXHighThinking, +} from "../plugins/provider-runtime.js"; + export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; export type VerboseLevel = "off" | "on" | "full"; export type NoticeLevel = "off" | "on" | "full"; @@ -27,8 +33,24 @@ function normalizeProviderId(provider?: string | null): string { return normalized; } -export function isBinaryThinkingProvider(provider?: string | null): boolean { - return normalizeProviderId(provider) === "zai"; +export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean { + const normalizedProvider = normalizeProviderId(provider); + if (!normalizedProvider) { + return false; + } + + const pluginDecision = resolveProviderBinaryThinking({ + provider: normalizedProvider, + context: { + provider: normalizedProvider, + modelId: model?.trim() ?? "", + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + + return normalizedProvider === "zai"; } export const XHIGH_MODEL_REFS = [ @@ -95,7 +117,19 @@ export function supportsXHighThinking(provider?: string | null, model?: string | if (!modelKey) { return false; } - const providerKey = provider?.trim().toLowerCase(); + const providerKey = normalizeProviderId(provider); + if (providerKey) { + const pluginDecision = resolveProviderXHighThinking({ + provider: providerKey, + context: { + provider: providerKey, + modelId: modelKey, + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + } if (providerKey) { return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`); } @@ -112,7 +146,7 @@ export function listThinkingLevels(provider?: string | null, model?: string | nu } export function listThinkingLevelLabels(provider?: string | null, model?: string | null): string[] { - if (isBinaryThinkingProvider(provider)) { + if (isBinaryThinkingProvider(provider, model)) { return ["off", "on"]; } return listThinkingLevels(provider, model); @@ -147,6 +181,21 @@ export function resolveThinkingDefaultForModel(params: { }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); const modelLower = params.model.trim().toLowerCase(); + const candidate = params.catalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const pluginDecision = resolveProviderDefaultThinkingLevel({ + provider: normalizedProvider, + context: { + provider: normalizedProvider, + modelId: params.model, + reasoning: candidate?.reasoning, + }, + }); + if (pluginDecision) { + return pluginDecision; + } + const isAnthropicFamilyModel = normalizedProvider === "anthropic" || normalizedProvider === "amazon-bedrock" || @@ -155,9 +204,6 @@ export function resolveThinkingDefaultForModel(params: { if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) { return "adaptive"; } - const candidate = params.catalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); if (candidate?.reasoning) { return "low"; } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index bf8195b5284..6bb052ba3d6 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { ProviderPlugin } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ @@ -15,8 +16,6 @@ const mocks = vi.hoisted(() => ({ upsertAuthProfile: vi.fn(), resolvePluginProviders: vi.fn(), createClackPrompter: vi.fn(), - loginOpenAICodexOAuth: vi.fn(), - writeOAuthCredentials: vi.fn(), loadValidConfigOrThrow: vi.fn(), updateConfig: vi.fn(), logConfigUpdated: vi.fn(), @@ -59,18 +58,6 @@ vi.mock("../../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); -vi.mock("../openai-codex-oauth.js", () => ({ - loginOpenAICodexOAuth: mocks.loginOpenAICodexOAuth, -})); - -vi.mock("../onboard-auth.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - writeOAuthCredentials: mocks.writeOAuthCredentials, - }; -}); - vi.mock("./shared.js", async (importActual) => { const actual = await importActual(); return { @@ -88,7 +75,8 @@ vi.mock("../onboard-helpers.js", () => ({ openUrl: mocks.openUrl, })); -const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand } = await import("./auth.js"); +const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand } = + await import("./auth.js"); function createRuntime(): RuntimeEnv { return { @@ -116,10 +104,30 @@ function withInteractiveStdin() { }; } +function createProvider(params: { + id: string; + label?: string; + run: NonNullable[number]["run"]; +}): ProviderPlugin { + return { + id: params.id, + label: params.label ?? params.id, + auth: [ + { + id: "oauth", + label: "OAuth", + kind: "oauth", + run: params.run, + }, + ], + }; +} + describe("modelsAuthLoginCommand", () => { let restoreStdin: (() => void) | null = null; let currentConfig: OpenClawConfig; let lastUpdatedConfig: OpenClawConfig | null; + let runProviderAuth: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -151,16 +159,29 @@ describe("modelsAuthLoginCommand", () => { note: vi.fn(async () => {}), select: vi.fn(), }); - mocks.loginOpenAICodexOAuth.mockResolvedValue({ - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - email: "user@example.com", + runProviderAuth = vi.fn().mockResolvedValue({ + profiles: [ + { + profileId: "openai-codex:user@example.com", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }, + }, + ], + defaultModel: "openai-codex/gpt-5.4", }); - mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com"); - mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolvePluginProviders.mockReturnValue([ + createProvider({ + id: "openai-codex", + label: "OpenAI Codex", + run: runProviderAuth as ProviderPlugin["auth"][number]["run"], + }), + ]); mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); mocks.listProfilesForProvider.mockReturnValue([]); mocks.clearAuthProfileCooldown.mockResolvedValue(undefined); @@ -171,19 +192,20 @@ describe("modelsAuthLoginCommand", () => { restoreStdin = null; }); - it("supports built-in openai-codex login without provider plugins", async () => { + it("runs plugin-owned openai-codex login", async () => { const runtime = createRuntime(); await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); - expect(mocks.writeOAuthCredentials).toHaveBeenCalledWith( - "openai-codex", - expect.any(Object), - "/tmp/openclaw/agents/main", - { syncSiblingAgents: true }, - ); - expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); + expect(runProviderAuth).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ + profileId: "openai-codex:user@example.com", + credential: expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + }), + agentDir: "/tmp/openclaw/agents/main", + }); expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ provider: "openai-codex", mode: "oauth", @@ -236,7 +258,7 @@ describe("modelsAuthLoginCommand", () => { }); // Verify clearing happens before login attempt const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]; - const loginOrder = mocks.loginOpenAICodexOAuth.mock.invocationCallOrder[0]; + const loginOrder = runProviderAuth.mock.invocationCallOrder[0]; expect(clearOrder).toBeLessThan(loginOrder); }); @@ -248,7 +270,7 @@ describe("modelsAuthLoginCommand", () => { await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); + expect(runProviderAuth).toHaveBeenCalledOnce(); }); it("loads lockout state from the agent-scoped store", async () => { @@ -261,11 +283,11 @@ describe("modelsAuthLoginCommand", () => { expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); }); - it("keeps existing plugin error behavior for non built-in providers", async () => { + it("reports loaded plugin providers when requested provider is unavailable", async () => { const runtime = createRuntime(); await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow( - "No provider plugins found.", + 'Unknown provider "anthropic". Loaded providers: openai-codex. Verify plugins via `openclaw plugins list --json`.', ); }); @@ -292,4 +314,47 @@ describe("modelsAuthLoginCommand", () => { exitSpy.mockRestore(); } }); + + it("runs token auth for any token-capable provider plugin", async () => { + const runtime = createRuntime(); + const runTokenAuth = vi.fn().mockResolvedValue({ + profiles: [ + { + profileId: "moonshot:token", + credential: { + type: "token", + provider: "moonshot", + token: "moonshot-token", + }, + }, + ], + }); + mocks.resolvePluginProviders.mockReturnValue([ + { + id: "moonshot", + label: "Moonshot", + auth: [ + { + id: "setup-token", + label: "setup-token", + kind: "token", + run: runTokenAuth, + }, + ], + }, + ]); + + await modelsAuthSetupTokenCommand({ provider: "moonshot", yes: true }, runtime); + + expect(runTokenAuth).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ + profileId: "moonshot:token", + credential: { + type: "token", + provider: "moonshot", + token: "moonshot-token", + }, + agentDir: "/tmp/openclaw/agents/main", + }); + }); }); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index c9b54b2f753..46ad67c41ef 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -21,22 +21,21 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; -import type { ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; +import type { + ProviderAuthMethod, + ProviderAuthResult, + ProviderPlugin, +} from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; -import { validateAnthropicSetupToken } from "../auth-token.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; -import { applyAuthProfileConfig, writeOAuthCredentials } from "../onboard-auth.js"; +import { applyAuthProfileConfig } from "../onboard-auth.js"; import { openUrl } from "../onboard-helpers.js"; -import { - applyOpenAICodexModelDefault, - OPENAI_CODEX_DEFAULT_MODEL, -} from "../openai-codex-model-default.js"; -import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js"; import { applyDefaultModel, mergeConfigPatch, @@ -78,40 +77,250 @@ const select = async (params: Parameters>[0]) => }), ); -type TokenProvider = "anthropic"; - -function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null { - const trimmed = raw?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeProviderId(trimmed); - if (normalized === "anthropic") { - return "anthropic"; - } - return "custom"; -} - function resolveDefaultTokenProfileId(provider: string): string { return `${normalizeProviderId(provider)}:manual`; } +type ResolvedModelsAuthContext = { + config: OpenClawConfig; + agentDir: string; + workspaceDir: string; + providers: ProviderPlugin[]; +}; + +function listProvidersWithAuthMethods(providers: ProviderPlugin[]): ProviderPlugin[] { + return providers.filter((provider) => provider.auth.length > 0); +} + +function listTokenAuthMethods(provider: ProviderPlugin): ProviderAuthMethod[] { + return provider.auth.filter((method) => method.kind === "token"); +} + +function listProvidersWithTokenMethods(providers: ProviderPlugin[]): ProviderPlugin[] { + return providers.filter((provider) => listTokenAuthMethods(provider).length > 0); +} + +async function resolveModelsAuthContext(): Promise { + const config = await loadValidConfigOrThrow(); + const defaultAgentId = resolveDefaultAgentId(config); + const agentDir = resolveAgentDir(config, defaultAgentId); + const workspaceDir = + resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); + const providers = resolvePluginProviders({ config, workspaceDir }); + return { config, agentDir, workspaceDir, providers }; +} + +function resolveRequestedProviderOrThrow( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const requested = rawProvider?.trim(); + if (!requested) { + return null; + } + const matched = resolveProviderMatch(providers, requested); + if (matched) { + return matched; + } + const available = providers + .map((provider) => provider.id) + .filter(Boolean) + .toSorted((a, b) => a.localeCompare(b)); + const availableText = available.length > 0 ? available.join(", ") : "(none)"; + throw new Error( + `Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`, + ); +} + +function resolveTokenMethodOrThrow( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { + const tokenMethods = listTokenAuthMethods(provider); + if (rawMethod?.trim()) { + const matched = pickAuthMethod(provider, rawMethod); + if (matched && matched.kind === "token") { + return matched; + } + const available = tokenMethods.map((method) => method.id).join(", ") || "(none)"; + throw new Error( + `Unknown token auth method "${rawMethod}" for provider "${provider.id}". Available token methods: ${available}.`, + ); + } + return null; +} + +async function pickProviderAuthMethod(params: { + provider: ProviderPlugin; + requestedMethod?: string; + prompter: ReturnType; +}) { + const requestedMethod = pickAuthMethod(params.provider, params.requestedMethod); + if (requestedMethod) { + return requestedMethod; + } + if (params.provider.auth.length === 1) { + return params.provider.auth[0] ?? null; + } + return await params.prompter + .select({ + message: `Auth method for ${params.provider.label}`, + options: params.provider.auth.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + }) + .then((id) => params.provider.auth.find((method) => method.id === String(id)) ?? null); +} + +async function pickProviderTokenMethod(params: { + provider: ProviderPlugin; + requestedMethod?: string; + prompter: ReturnType; +}) { + const explicitTokenMethod = resolveTokenMethodOrThrow(params.provider, params.requestedMethod); + if (explicitTokenMethod) { + return explicitTokenMethod; + } + const tokenMethods = listTokenAuthMethods(params.provider); + if (tokenMethods.length === 0) { + return null; + } + const setupTokenMethod = tokenMethods.find((method) => method.id === "setup-token"); + if (setupTokenMethod) { + return setupTokenMethod; + } + if (tokenMethods.length === 1) { + return tokenMethods[0] ?? null; + } + return await params.prompter + .select({ + message: `Token method for ${params.provider.label}`, + options: tokenMethods.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + }) + .then((id) => tokenMethods.find((method) => method.id === String(id)) ?? null); +} + +async function persistProviderAuthResult(params: { + result: ProviderAuthResult; + agentDir: string; + runtime: RuntimeEnv; + prompter: ReturnType; + setDefault?: boolean; +}) { + for (const profile of params.result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir: params.agentDir, + }); + } + + await updateConfig((cfg) => { + let next = cfg; + if (params.result.configPatch) { + next = mergeConfigPatch(next, params.result.configPatch); + } + for (const profile of params.result.profiles) { + next = applyAuthProfileConfig(next, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: credentialMode(profile.credential), + }); + } + if (params.setDefault && params.result.defaultModel) { + next = applyDefaultModel(next, params.result.defaultModel); + } + return next; + }); + + logConfigUpdated(params.runtime); + for (const profile of params.result.profiles) { + params.runtime.log( + `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, + ); + } + if (params.result.defaultModel) { + params.runtime.log( + params.setDefault + ? `Default model set to ${params.result.defaultModel}` + : `Default model available: ${params.result.defaultModel} (use --set-default to apply)`, + ); + } + if (params.result.notes && params.result.notes.length > 0) { + await params.prompter.note(params.result.notes.join("\n"), "Provider notes"); + } +} + +async function runProviderAuthMethod(params: { + config: OpenClawConfig; + agentDir: string; + workspaceDir: string; + provider: ProviderPlugin; + method: ProviderAuthMethod; + runtime: RuntimeEnv; + prompter: ReturnType; + setDefault?: boolean; +}) { + await clearStaleProfileLockouts(params.provider.id, params.agentDir); + + const result = await params.method.run({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + prompter: params.prompter, + runtime: params.runtime, + isRemote: isRemoteEnvironment(), + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (runtimeParams) => createVpsAwareOAuthHandlers(runtimeParams), + }, + }); + + await persistProviderAuthResult({ + result, + agentDir: params.agentDir, + runtime: params.runtime, + prompter: params.prompter, + setDefault: params.setDefault, + }); +} + export async function modelsAuthSetupTokenCommand( opts: { provider?: string; yes?: boolean }, runtime: RuntimeEnv, ) { - const provider = resolveTokenProvider(opts.provider ?? "anthropic"); - if (provider !== "anthropic") { - throw new Error("Only --provider anthropic is supported for setup-token."); - } - if (!process.stdin.isTTY) { throw new Error("setup-token requires an interactive TTY."); } + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); + const tokenProviders = listProvidersWithTokenMethods(providers); + if (tokenProviders.length === 0) { + throw new Error( + `No provider token-auth plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`, + ); + } + + const provider = + resolveRequestedProviderOrThrow(tokenProviders, opts.provider ?? "anthropic") ?? + tokenProviders.find((candidate) => normalizeProviderId(candidate.id) === "anthropic") ?? + tokenProviders[0] ?? + null; + if (!provider) { + throw new Error("No token-capable provider is available."); + } + if (!opts.yes) { const proceed = await confirm({ - message: "Have you run `claude setup-token` and copied the token?", + message: `Continue with ${provider.label} token auth?`, initialValue: true, }); if (!proceed) { @@ -119,32 +328,21 @@ export async function modelsAuthSetupTokenCommand( } } - const tokenInput = await text({ - message: "Paste Anthropic setup-token", - validate: (value) => validateAnthropicSetupToken(String(value ?? "")), + const prompter = createClackPrompter(); + const method = await pickProviderTokenMethod({ provider, prompter }); + if (!method) { + throw new Error(`Provider "${provider.id}" does not expose a token auth method.`); + } + + await runProviderAuthMethod({ + config, + agentDir, + workspaceDir, + provider, + method, + runtime, + prompter, }); - const token = String(tokenInput ?? "").trim(); - const profileId = resolveDefaultTokenProfileId(provider); - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider, - token, - }, - }); - - await updateConfig((cfg) => - applyAuthProfileConfig(cfg, { - profileId, - provider, - mode: "token", - }), - ); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (${provider}/token)`); } export async function modelsAuthPasteTokenCommand( @@ -190,10 +388,17 @@ export async function modelsAuthPasteTokenCommand( } export async function modelsAuthAddCommand(_opts: Record, runtime: RuntimeEnv) { + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); + const tokenProviders = listProvidersWithTokenMethods(providers); + const provider = await select({ message: "Token provider", options: [ - { value: "anthropic", label: "anthropic" }, + ...tokenProviders.map((providerPlugin) => ({ + value: providerPlugin.id, + label: providerPlugin.id, + hint: providerPlugin.docsPath ? `Docs: ${providerPlugin.docsPath}` : undefined, + })), { value: "custom", label: "custom (type provider id)" }, ], }); @@ -210,25 +415,41 @@ export async function modelsAuthAddCommand(_opts: Record, runtime ) : provider; - const method = (await select({ - message: "Token method", - options: [ - ...(providerId === "anthropic" - ? [ - { - value: "setup-token", - label: "setup-token (claude)", - hint: "Paste a setup-token from `claude setup-token`", - }, - ] - : []), - { value: "paste", label: "paste token" }, - ], - })) as "setup-token" | "paste"; - - if (method === "setup-token") { - await modelsAuthSetupTokenCommand({ provider: providerId }, runtime); - return; + const providerPlugin = + provider === "custom" ? null : resolveRequestedProviderOrThrow(tokenProviders, providerId); + if (providerPlugin) { + const tokenMethods = listTokenAuthMethods(providerPlugin); + const methodId = + tokenMethods.length > 0 + ? await select({ + message: "Token method", + options: [ + ...tokenMethods.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + { value: "paste", label: "paste token" }, + ], + }) + : "paste"; + if (methodId !== "paste") { + const prompter = createClackPrompter(); + const method = tokenMethods.find((candidate) => candidate.id === methodId); + if (!method) { + throw new Error(`Unknown token auth method "${String(methodId)}".`); + } + await runProviderAuthMethod({ + config, + agentDir, + workspaceDir, + provider: providerPlugin, + method, + runtime, + prompter, + }); + return; + } } const profileIdDefault = resolveDefaultTokenProfileId(providerId); @@ -292,22 +513,7 @@ export function resolveRequestedLoginProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, ): ProviderPlugin | null { - const requested = rawProvider?.trim(); - if (!requested) { - return null; - } - const matched = resolveProviderMatch(providers, requested); - if (matched) { - return matched; - } - const available = providers - .map((provider) => provider.id) - .filter(Boolean) - .toSorted((a, b) => a.localeCompare(b)); - const availableText = available.length > 0 ? available.join(", ") : "(none)"; - throw new Error( - `Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`, - ); + return resolveRequestedProviderOrThrow(providers, rawProvider); } function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" { @@ -320,177 +526,55 @@ function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" return "oauth"; } -async function runBuiltInOpenAICodexLogin(params: { - opts: LoginOptions; - runtime: RuntimeEnv; - prompter: ReturnType; - agentDir: string; -}) { - const creds = await loginOpenAICodexOAuth({ - prompter: params.prompter, - runtime: params.runtime, - isRemote: isRemoteEnvironment(), - openUrl: async (url) => { - await openUrl(url); - }, - localBrowserMessage: "Complete sign-in in browser…", - }); - if (!creds) { - throw new Error("OpenAI Codex OAuth did not return credentials."); - } - - const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, { - syncSiblingAgents: true, - }); - await updateConfig((cfg) => { - let next = applyAuthProfileConfig(cfg, { - profileId, - provider: "openai-codex", - mode: "oauth", - }); - if (params.opts.setDefault) { - next = applyOpenAICodexModelDefault(next).next; - } - return next; - }); - - logConfigUpdated(params.runtime); - params.runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`); - if (params.opts.setDefault) { - params.runtime.log(`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`); - } else { - params.runtime.log( - `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`, - ); - } -} - export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { if (!process.stdin.isTTY) { throw new Error("models auth login requires an interactive TTY."); } - const config = await loadValidConfigOrThrow(); - const defaultAgentId = resolveDefaultAgentId(config); - const agentDir = resolveAgentDir(config, defaultAgentId); - const workspaceDir = - resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); - const requestedProviderId = normalizeProviderId(String(opts.provider ?? "")); + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); const prompter = createClackPrompter(); - - if (requestedProviderId === "openai-codex") { - await clearStaleProfileLockouts("openai-codex", agentDir); - await runBuiltInOpenAICodexLogin({ - opts, - runtime, - prompter, - agentDir, - }); - return; - } - - const providers = resolvePluginProviders({ config, workspaceDir }); - if (providers.length === 0) { + const authProviders = listProvidersWithAuthMethods(providers); + if (authProviders.length === 0) { throw new Error( `No provider plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`, ); } - const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider); + const requestedProvider = resolveRequestedLoginProviderOrThrow(authProviders, opts.provider); const selectedProvider = requestedProvider ?? (await prompter .select({ message: "Select a provider", - options: providers.map((provider) => ({ + options: authProviders.map((provider) => ({ value: provider.id, label: provider.label, hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined, })), }) - .then((id) => resolveProviderMatch(providers, String(id)))); + .then((id) => resolveProviderMatch(authProviders, String(id)))); if (!selectedProvider) { throw new Error("Unknown provider. Use --provider to pick a provider plugin."); } - - await clearStaleProfileLockouts(selectedProvider.id, agentDir); - - const chosenMethod = - pickAuthMethod(selectedProvider, opts.method) ?? - (selectedProvider.auth.length === 1 - ? selectedProvider.auth[0] - : await prompter - .select({ - message: `Auth method for ${selectedProvider.label}`, - options: selectedProvider.auth.map((method) => ({ - value: method.id, - label: method.label, - hint: method.hint, - })), - }) - .then((id) => selectedProvider.auth.find((method) => method.id === String(id)))); + const chosenMethod = await pickProviderAuthMethod({ + provider: selectedProvider, + requestedMethod: opts.method, + prompter, + }); if (!chosenMethod) { throw new Error("Unknown auth method. Use --method to select one."); } - const isRemote = isRemoteEnvironment(); - const result: ProviderAuthResult = await chosenMethod.run({ + await runProviderAuthMethod({ config, agentDir, workspaceDir, - prompter, + provider: selectedProvider, + method: chosenMethod, runtime, - isRemote, - openUrl: async (url) => { - await openUrl(url); - }, - oauth: { - createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params), - }, + prompter, + setDefault: opts.setDefault, }); - - for (const profile of result.profiles) { - upsertAuthProfile({ - profileId: profile.profileId, - credential: profile.credential, - agentDir, - }); - } - - await updateConfig((cfg) => { - let next = cfg; - if (result.configPatch) { - next = mergeConfigPatch(next, result.configPatch); - } - for (const profile of result.profiles) { - next = applyAuthProfileConfig(next, { - profileId: profile.profileId, - provider: profile.credential.provider, - mode: credentialMode(profile.credential), - }); - } - if (opts.setDefault && result.defaultModel) { - next = applyDefaultModel(next, result.defaultModel); - } - return next; - }); - - logConfigUpdated(runtime); - for (const profile of result.profiles) { - runtime.log( - `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, - ); - } - if (result.defaultModel) { - runtime.log( - opts.setDefault - ? `Default model set to ${result.defaultModel}` - : `Default model available: ${result.defaultModel} (use --set-default to apply)`, - ); - } - if (result.notes && result.notes.length > 0) { - await prompter.note(result.notes.join("\n"), "Provider notes"); - } } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index a792af23816..f3a6d1ca16b 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -10,7 +10,9 @@ export type { ProviderBuiltInModelSuppressionResult, ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPreparedRuntimeAuth, ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, @@ -20,6 +22,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, ProviderAuthContext, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ba5583d2c4a..6ad093eec91 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -114,7 +114,9 @@ export type { ProviderBuiltInModelSuppressionResult, ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPreparedRuntimeAuth, ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, @@ -124,6 +126,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "../plugins/types.js"; export type { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index e38d6553080..23234be8109 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -17,10 +17,14 @@ import { buildProviderMissingAuthMessageWithPlugin, prepareProviderExtraParams, resolveProviderCacheTtlEligibility, + resolveProviderBinaryThinking, resolveProviderBuiltInModelSuppression, + resolveProviderDefaultThinkingLevel, + resolveProviderModernModelRef, resolveProviderUsageSnapshotWithPlugin, resolveProviderCapabilitiesWithPlugin, resolveProviderUsageAuthWithPlugin, + resolveProviderXHighThinking, normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, @@ -143,6 +147,10 @@ describe("provider-runtime", () => { resolveUsageAuth, fetchUsageSnapshot, isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), + isBinaryThinking: () => true, + supportsXHighThinking: ({ modelId }) => modelId === "gpt-5.4", + resolveDefaultThinkingLevel: ({ reasoning }) => (reasoning ? "low" : "off"), + isModernModelRef: ({ modelId }) => modelId.startsWith("gpt-5"), }, ]; }); @@ -278,6 +286,47 @@ describe("provider-runtime", () => { }), ).toBe(true); + expect( + resolveProviderBinaryThinking({ + provider: "demo", + context: { + provider: "demo", + modelId: "glm-5", + }, + }), + ).toBe(true); + + expect( + resolveProviderXHighThinking({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + }, + }), + ).toBe(true); + + expect( + resolveProviderDefaultThinkingLevel({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + reasoning: true, + }, + }), + ).toBe("low"); + + expect( + resolveProviderModernModelRef({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + }, + }), + ).toBe(true); + expect( buildProviderMissingAuthMessageWithPlugin({ provider: "openai", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 9e5104f7f86..8997011a7c9 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -6,7 +6,9 @@ import type { ProviderBuildMissingAuthMessageContext, ProviderBuiltInModelSuppressionContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, ProviderPrepareRuntimeAuthContext, @@ -14,6 +16,7 @@ import type { ProviderPlugin, ProviderResolveDynamicModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "./types.js"; @@ -179,6 +182,46 @@ export function resolveProviderCacheTtlEligibility(params: { return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context); } +export function resolveProviderBinaryThinking(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.isBinaryThinking?.(params.context); +} + +export function resolveProviderXHighThinking(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.supportsXHighThinking?.(params.context); +} + +export function resolveProviderDefaultThinkingLevel(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderDefaultThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.resolveDefaultThinkingLevel?.(params.context); +} + +export function resolveProviderModernModelRef(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderModernModelPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.isModernModelRef?.(params.context); +} + export function buildProviderMissingAuthMessageWithPlugin(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 685858a9b6e..df7e00734d5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -426,6 +426,40 @@ export type ProviderBuiltInModelSuppressionResult = { errorMessage?: string; }; +/** + * Provider-owned thinking policy input. + * + * Used by shared `/think`, ACP controls, and directive parsing to ask a + * provider whether a model supports special reasoning UX such as xhigh or a + * binary on/off toggle. + */ +export type ProviderThinkingPolicyContext = { + provider: string; + modelId: string; +}; + +/** + * Provider-owned default thinking policy input. + * + * `reasoning` is the merged catalog hint for the selected model when one is + * available. Providers can use it to keep "reasoning model => low" behavior + * without re-reading the catalog themselves. + */ +export type ProviderDefaultThinkingPolicyContext = ProviderThinkingPolicyContext & { + reasoning?: boolean; +}; + +/** + * Provider-owned "modern model" policy input. + * + * Live smoke/model-profile selection uses this to keep provider-specific + * inclusion/exclusion rules out of core. + */ +export type ProviderModernModelPolicyContext = { + provider: string; + modelId: string; +}; + /** * Final catalog augmentation hook. * @@ -651,6 +685,35 @@ export type ProviderPlugin = { | Promise | ReadonlyArray | null | undefined> | null | undefined; + /** + * Provider-owned binary thinking toggle. + * + * Return true when the provider exposes a coarse on/off reasoning control + * instead of the normal multi-level ladder shown by `/think`. + */ + isBinaryThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined; + /** + * Provider-owned xhigh reasoning support. + * + * Return true only for models that should expose the `xhigh` thinking level. + */ + supportsXHighThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined; + /** + * Provider-owned default thinking level. + * + * Use this to keep model-family defaults (for example Claude 4.6 => + * adaptive) out of core command logic. + */ + resolveDefaultThinkingLevel?: ( + ctx: ProviderDefaultThinkingPolicyContext, + ) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined; + /** + * Provider-owned "modern model" matcher used by live profile/smoke filters. + * + * Return true when the given provider/model ref should be treated as a + * preferred modern model candidate. + */ + isModernModelRef?: (ctx: ProviderModernModelPolicyContext) => boolean | undefined; wizard?: ProviderPluginWizard; formatApiKey?: (cred: AuthProfileCredential) => string; refreshOAuth?: (cred: OAuthCredential) => Promise; From 01456f95bc5fd41243d02a33fb71abef57eb6c67 Mon Sep 17 00:00:00 2001 From: Christopher Chamaletsos Date: Sun, 15 Mar 2026 20:21:04 +0200 Subject: [PATCH 094/331] fix: control UI sends correct provider prefix when switching models The model selector was using just the model ID (e.g. "gpt-5.2") as the option value. When sent to sessions.patch, the server would fall back to the session's current provider ("anthropic") yielding "anthropic/gpt-5.2" instead of "openai/gpt-5.2". Now option values use "provider/model" format, and resolveModelOverrideValue and resolveDefaultModelValue also return the full provider-prefixed key so selected state stays consistent. --- ui/src/ui/app-render.helpers.ts | 19 ++++++++++++++----- ui/src/ui/types.ts | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 77ba247a26d..db6dfc40861 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -529,16 +529,24 @@ function resolveModelOverrideValue(state: AppViewState): string { return ""; } // No local override recorded yet — fall back to server data. + // Include provider prefix so the value matches option keys (provider/model). const activeRow = resolveActiveSessionRow(state); - if (activeRow) { - return typeof activeRow.model === "string" ? activeRow.model.trim() : ""; + if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { + const provider = activeRow.modelProvider?.trim(); + const model = activeRow.model.trim(); + return provider ? `${provider}/${model}` : model; } return ""; } function resolveDefaultModelValue(state: AppViewState): string { - const model = state.sessionsResult?.defaults?.model; - return typeof model === "string" ? model.trim() : ""; + const defaults = state.sessionsResult?.defaults; + const model = defaults?.model; + if (typeof model !== "string" || !model.trim()) { + return ""; + } + const provider = defaults?.modelProvider?.trim(); + return provider ? `${provider}/${model.trim()}` : model.trim(); } function buildChatModelOptions( @@ -563,7 +571,8 @@ function buildChatModelOptions( for (const entry of catalog) { const provider = entry.provider?.trim(); - addOption(entry.id, provider ? `${entry.id} · ${provider}` : entry.id); + const value = provider ? `${provider}/${entry.id}` : entry.id; + addOption(value, provider ? `${entry.id} · ${provider}` : entry.id); } if (currentOverride) { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index d9764a024e6..82c97c6744a 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -316,6 +316,7 @@ export type PresenceEntry = { }; export type GatewaySessionsDefaults = { + modelProvider: string | null; model: string | null; contextTokens: number | null; }; From d9fb50e7772177e7f739f7598401f30da5ad0bc8 Mon Sep 17 00:00:00 2001 From: Christopher Chamaletsos Date: Sun, 15 Mar 2026 21:05:24 +0200 Subject: [PATCH 095/331] =?UTF-8?q?fix:=20format=20default=20model=20label?= =?UTF-8?q?=20as=20'model=20=C2=B7=20provider'=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default option showed 'Default (openai/gpt-5.2)' while individual options used the friendlier 'gpt-5.2 · openai' format. --- ui/src/ui/app-render.helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index db6dfc40861..12e239cb50d 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -592,7 +592,10 @@ function renderChatModelSelect(state: AppViewState) { currentOverride, defaultModel, ); - const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default model"; + const defaultDisplay = defaultModel.includes("/") + ? `${defaultModel.slice(defaultModel.indexOf("/") + 1)} · ${defaultModel.slice(0, defaultModel.indexOf("/"))}` + : defaultModel; + const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; const disabled = From 31e6cb0df6293fa8e46fd894b9c51c9d465457df Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:46:49 -0700 Subject: [PATCH 096/331] Nostr: break setup-surface import cycle --- extensions/nostr/src/default-relays.ts | 1 + extensions/nostr/src/nostr-bus.ts | 3 +-- extensions/nostr/src/setup-surface.ts | 3 ++- extensions/nostr/src/types.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 extensions/nostr/src/default-relays.ts diff --git a/extensions/nostr/src/default-relays.ts b/extensions/nostr/src/default-relays.ts new file mode 100644 index 00000000000..f9b6be01cba --- /dev/null +++ b/extensions/nostr/src/default-relays.ts @@ -0,0 +1 @@ +export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 0b015dad29f..f7fa1d4d94f 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -8,6 +8,7 @@ import { } from "nostr-tools"; import { decrypt, encrypt } from "nostr-tools/nip04"; import type { NostrProfile } from "./config-schema.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; import { createMetrics, createNoopMetrics, @@ -25,8 +26,6 @@ import { } from "./nostr-state-store.js"; import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; -export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; - // ============================================================================ // Constants // ============================================================================ diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 800b2705258..84c78743cb3 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -13,7 +13,8 @@ import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; +import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; import { resolveNostrAccount } from "./types.js"; const channel = "nostr" as const; diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 9baf78a0ca8..e2419c44ac3 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -5,8 +5,8 @@ import { } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import type { NostrProfile } from "./config-schema.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate } from "./nostr-bus.js"; -import { DEFAULT_RELAYS } from "./nostr-bus.js"; export interface NostrAccountConfig { enabled?: boolean; From 7d5e26b4a283882787f71ef4d7151f03a2976a05 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:47:20 -0700 Subject: [PATCH 097/331] Tests: stabilize bundle MCP env on Windows --- src/plugins/bundle-mcp.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 122c7a83c5c..ef109f4abfb 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -24,11 +24,14 @@ afterEach(async () => { describe("loadEnabledBundleMcpConfig", () => { it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { - const env = captureEnv(["HOME"]); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { const homeDir = await createTempDir("openclaw-bundle-mcp-home-"); const workspaceDir = await createTempDir("openclaw-bundle-mcp-workspace-"); process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); @@ -80,11 +83,14 @@ describe("loadEnabledBundleMcpConfig", () => { }); it("merges inline bundle MCP servers and skips disabled bundles", async () => { - const env = captureEnv(["HOME"]); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { const homeDir = await createTempDir("openclaw-bundle-inline-home-"); const workspaceDir = await createTempDir("openclaw-bundle-inline-workspace-"); process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled"); const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled"); From 270ba54c4747e2f3b1d0c6f3c0f4f019d958e657 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:48:07 -0700 Subject: [PATCH 098/331] Status: lazy-load channel security and summaries --- src/security/audit.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/security/audit.ts b/src/security/audit.ts index d3c1337e042..b304f658d68 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -6,7 +6,7 @@ import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import type { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; @@ -137,6 +137,13 @@ type AuditExecutionContext = { deepProbeAuth?: { token?: string; password?: string }; }; +let channelPluginsModulePromise: Promise | undefined; + +async function loadChannelPlugins() { + channelPluginsModulePromise ??= import("../channels/plugins/index.js"); + return await channelPluginsModulePromise; +} + function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { let critical = 0; let warn = 0; @@ -1244,7 +1251,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 20:52:33 -0700 Subject: [PATCH 099/331] Docs: refresh generated config baseline --- docs/.generated/config-baseline.json | 2110 ++++++++++++++++++++++++- docs/.generated/config-baseline.jsonl | 173 +- 2 files changed, 2252 insertions(+), 31 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index f6f854b2946..6dc7cc100f2 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -2956,6 +2956,16 @@ "tags": [], "hasChildren": true }, + { + "path": "agents.defaults.sandbox.backend", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.defaults.sandbox.browser", "kind": "core", @@ -5048,6 +5058,16 @@ "tags": [], "hasChildren": true }, + { + "path": "agents.list.*.sandbox.backend", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.list.*.sandbox.browser", "kind": "core", @@ -30047,6 +30067,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.actions.editForumTopic", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.actions.editMessage", "kind": "channel", @@ -31930,6 +31960,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.actions.editForumTopic", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.actions.editMessage", "kind": "channel", @@ -44497,6 +44537,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.anthropic", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/anthropic-provider", + "help": "OpenClaw Anthropic provider plugin (plugin: anthropic)", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/anthropic-provider Config", + "help": "Plugin-defined config payload for anthropic.", + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/anthropic-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.bluebubbles", "kind": "plugin", @@ -44566,6 +44675,213 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.brave", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/brave-plugin", + "help": "OpenClaw Brave plugin (plugin: brave)", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/brave-plugin Config", + "help": "Plugin-defined config payload for brave.", + "hasChildren": false + }, + { + "path": "plugins.entries.brave.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/brave-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.brave.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/byteplus-provider", + "help": "OpenClaw BytePlus provider plugin (plugin: byteplus)", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/byteplus-provider Config", + "help": "Plugin-defined config payload for byteplus.", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/byteplus-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/cloudflare-ai-gateway-provider", + "help": "OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/cloudflare-ai-gateway-provider Config", + "help": "Plugin-defined config payload for cloudflare-ai-gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/cloudflare-ai-gateway-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.copilot-proxy", "kind": "plugin", @@ -45332,7 +45648,7 @@ "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth", + "path": "plugins.entries.github-copilot", "kind": "plugin", "type": "object", "required": false, @@ -45341,12 +45657,12 @@ "tags": [ "advanced" ], - "label": "@openclaw/google-gemini-cli-auth", - "help": "OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)", + "label": "@openclaw/github-copilot-provider", + "help": "OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)", "hasChildren": true }, { - "path": "plugins.entries.google-gemini-cli-auth.config", + "path": "plugins.entries.github-copilot.config", "kind": "plugin", "type": "object", "required": false, @@ -45355,12 +45671,12 @@ "tags": [ "advanced" ], - "label": "@openclaw/google-gemini-cli-auth Config", - "help": "Plugin-defined config payload for google-gemini-cli-auth.", + "label": "@openclaw/github-copilot-provider Config", + "help": "Plugin-defined config payload for github-copilot.", "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth.enabled", + "path": "plugins.entries.github-copilot.enabled", "kind": "plugin", "type": "boolean", "required": false, @@ -45369,11 +45685,11 @@ "tags": [ "advanced" ], - "label": "Enable @openclaw/google-gemini-cli-auth", + "label": "Enable @openclaw/github-copilot-provider", "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth.hooks", + "path": "plugins.entries.github-copilot.hooks", "kind": "plugin", "type": "object", "required": false, @@ -45387,7 +45703,76 @@ "hasChildren": true }, { - "path": "plugins.entries.google-gemini-cli-auth.hooks.allowPromptInjection", + "path": "plugins.entries.github-copilot.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.google", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/google-plugin", + "help": "OpenClaw Google plugin (plugin: google)", + "hasChildren": true + }, + { + "path": "plugins.entries.google.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/google-plugin Config", + "help": "Plugin-defined config payload for google.", + "hasChildren": false + }, + { + "path": "plugins.entries.google.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/google-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.google.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.hooks.allowPromptInjection", "kind": "plugin", "type": "boolean", "required": false, @@ -45469,6 +45854,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.huggingface", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/huggingface-provider", + "help": "OpenClaw Hugging Face provider plugin (plugin: huggingface)", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/huggingface-provider Config", + "help": "Plugin-defined config payload for huggingface.", + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/huggingface-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.imessage", "kind": "plugin", @@ -45607,6 +46061,144 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kilocode", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kilocode-provider", + "help": "OpenClaw Kilo Gateway provider plugin (plugin: kilocode)", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kilocode-provider Config", + "help": "Plugin-defined config payload for kilocode.", + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/kilocode-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kimi-coding-provider", + "help": "OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi-coding.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kimi-coding-provider Config", + "help": "Plugin-defined config payload for kimi-coding.", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/kimi-coding-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi-coding.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.line", "kind": "plugin", @@ -46290,7 +46882,7 @@ "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth", + "path": "plugins.entries.minimax", "kind": "plugin", "type": "object", "required": false, @@ -46299,12 +46891,12 @@ "tags": [ "performance" ], - "label": "@openclaw/minimax-portal-auth", - "help": "OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)", + "label": "@openclaw/minimax-provider", + "help": "OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)", "hasChildren": true }, { - "path": "plugins.entries.minimax-portal-auth.config", + "path": "plugins.entries.minimax.config", "kind": "plugin", "type": "object", "required": false, @@ -46313,12 +46905,12 @@ "tags": [ "performance" ], - "label": "@openclaw/minimax-portal-auth Config", - "help": "Plugin-defined config payload for minimax-portal-auth.", + "label": "@openclaw/minimax-provider Config", + "help": "Plugin-defined config payload for minimax.", "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth.enabled", + "path": "plugins.entries.minimax.enabled", "kind": "plugin", "type": "boolean", "required": false, @@ -46327,11 +46919,11 @@ "tags": [ "performance" ], - "label": "Enable @openclaw/minimax-portal-auth", + "label": "Enable @openclaw/minimax-provider", "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth.hooks", + "path": "plugins.entries.minimax.hooks", "kind": "plugin", "type": "object", "required": false, @@ -46345,7 +46937,214 @@ "hasChildren": true }, { - "path": "plugins.entries.minimax-portal-auth.hooks.allowPromptInjection", + "path": "plugins.entries.minimax.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/mistral-provider", + "help": "OpenClaw Mistral provider plugin (plugin: mistral)", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/mistral-provider Config", + "help": "Plugin-defined config payload for mistral.", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/mistral-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/modelstudio-provider", + "help": "OpenClaw Model Studio provider plugin (plugin: modelstudio)", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/modelstudio-provider Config", + "help": "Plugin-defined config payload for modelstudio.", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/modelstudio-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/moonshot-provider", + "help": "OpenClaw Moonshot provider plugin (plugin: moonshot)", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/moonshot-provider Config", + "help": "Plugin-defined config payload for moonshot.", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/moonshot-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.hooks.allowPromptInjection", "kind": "plugin", "type": "boolean", "required": false, @@ -46565,6 +47364,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nvidia", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/nvidia-provider", + "help": "OpenClaw NVIDIA provider plugin (plugin: nvidia)", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/nvidia-provider Config", + "help": "Plugin-defined config payload for nvidia.", + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/nvidia-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.ollama", "kind": "plugin", @@ -46703,6 +47571,587 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openai-provider", + "help": "OpenClaw OpenAI provider plugins (plugin: openai)", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openai-provider Config", + "help": "Plugin-defined config payload for openai.", + "hasChildren": false + }, + { + "path": "plugins.entries.openai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/openai-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.openai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-provider", + "help": "OpenClaw OpenCode Zen provider plugin (plugin: opencode)", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-go-provider", + "help": "OpenClaw OpenCode Go provider plugin (plugin: opencode-go)", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-go-provider Config", + "help": "Plugin-defined config payload for opencode-go.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/opencode-go-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-provider Config", + "help": "Plugin-defined config payload for opencode.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/opencode-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openrouter-provider", + "help": "OpenClaw OpenRouter provider plugin (plugin: openrouter)", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openrouter-provider Config", + "help": "Plugin-defined config payload for openrouter.", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/openrouter-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Sandbox", + "help": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Sandbox Config", + "help": "Plugin-defined config payload for openshell.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config.autoProviders", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Auto-create Providers", + "help": "When enabled, pass --auto-providers during sandbox create.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.command", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Command", + "help": "Path or command name for the openshell CLI.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.from", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Sandbox Source", + "help": "OpenShell sandbox source for first-time create. Defaults to openclaw.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gateway", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Gateway Name", + "help": "Optional OpenShell gateway name passed as --gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gatewayEndpoint", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Gateway Endpoint", + "help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gpu", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "GPU", + "help": "Request GPU resources when creating the sandbox.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.policy", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Policy File", + "help": "Optional path to a custom OpenShell sandbox policy YAML.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.providers", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Providers", + "help": "Provider names to attach when a sandbox is created.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config.providers.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.remoteAgentWorkspaceDir", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "storage" + ], + "label": "Remote Agent Dir", + "help": "Mirror path for the real agent workspace when workspaceAccess is read-only.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.remoteWorkspaceDir", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "storage" + ], + "label": "Remote Workspace Dir", + "help": "Primary writable workspace inside the OpenShell sandbox.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.timeoutSeconds", + "kind": "plugin", + "type": "number", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "performance" + ], + "label": "Command Timeout Seconds", + "help": "Timeout for openshell CLI operations such as create/upload/download.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable OpenShell Sandbox", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/perplexity-plugin", + "help": "OpenClaw Perplexity plugin (plugin: perplexity)", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/perplexity-plugin Config", + "help": "Plugin-defined config payload for perplexity.", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/perplexity-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.phone-control", "kind": "plugin", @@ -46772,6 +48221,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qianfan", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qianfan-provider", + "help": "OpenClaw Qianfan provider plugin (plugin: qianfan)", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qianfan-provider Config", + "help": "Plugin-defined config payload for qianfan.", + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/qianfan-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.qwen-portal-auth", "kind": "plugin", @@ -47117,6 +48635,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synthetic", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/synthetic-provider", + "help": "OpenClaw Synthetic provider plugin (plugin: synthetic)", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/synthetic-provider Config", + "help": "Plugin-defined config payload for synthetic.", + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/synthetic-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.talk-voice", "kind": "plugin", @@ -47431,6 +49018,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.together", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/together-provider", + "help": "OpenClaw Together provider plugin (plugin: together)", + "hasChildren": true + }, + { + "path": "plugins.entries.together.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/together-provider Config", + "help": "Plugin-defined config payload for together.", + "hasChildren": false + }, + { + "path": "plugins.entries.together.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/together-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.together.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.twitch", "kind": "plugin", @@ -47500,6 +49156,144 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.venice", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/venice-provider", + "help": "OpenClaw Venice provider plugin (plugin: venice)", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/venice-provider Config", + "help": "Plugin-defined config payload for venice.", + "hasChildren": false + }, + { + "path": "plugins.entries.venice.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/venice-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.venice.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/vercel-ai-gateway-provider", + "help": "OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/vercel-ai-gateway-provider Config", + "help": "Plugin-defined config payload for vercel-ai-gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/vercel-ai-gateway-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.vllm", "kind": "plugin", @@ -48999,6 +50793,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.volcengine", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/volcengine-provider", + "help": "OpenClaw Volcengine provider plugin (plugin: volcengine)", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/volcengine-provider Config", + "help": "Plugin-defined config payload for volcengine.", + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/volcengine-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.whatsapp", "kind": "plugin", @@ -49068,6 +50931,213 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xai-plugin", + "help": "OpenClaw xAI plugin (plugin: xai)", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xai-plugin Config", + "help": "Plugin-defined config payload for xai.", + "hasChildren": false + }, + { + "path": "plugins.entries.xai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/xai-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.xai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xiaomi-provider", + "help": "OpenClaw Xiaomi provider plugin (plugin: xiaomi)", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xiaomi-provider Config", + "help": "Plugin-defined config payload for xiaomi.", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/xiaomi-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.zai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/zai-provider", + "help": "OpenClaw Z.AI provider plugin (plugin: zai)", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/zai-provider Config", + "help": "Plugin-defined config payload for zai.", + "hasChildren": false + }, + { + "path": "plugins.entries.zai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/zai-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.zai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.zalo", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 18baeac12b9..65552724518 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5040} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -245,6 +245,7 @@ {"recordType":"path","path":"agents.defaults.pdfModel.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"PDF Model","help":"Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.","hasChildren":false} {"recordType":"path","path":"agents.defaults.repoRoot","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Repo Root","help":"Optional repository root shown in the system prompt runtime line (overrides auto-detect).","hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"agents.defaults.sandbox.backend","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox.browser","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.sandbox.browser.allowHostControl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox.browser.autoStart","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -445,6 +446,7 @@ {"recordType":"path","path":"agents.list.*.runtime.acp.mode","kind":"core","type":"string","required":false,"enumValues":["persistent","oneshot"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Mode","help":"Optional ACP session mode default for this agent (persistent or oneshot).","hasChildren":false} {"recordType":"path","path":"agents.list.*.runtime.type","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Runtime Type","help":"Runtime type for this agent: \"embedded\" (default OpenClaw runtime) or \"acp\" (ACP harness defaults).","hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"agents.list.*.sandbox.backend","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox.browser","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.sandbox.browser.allowHostControl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox.browser.autoStart","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2708,6 +2710,7 @@ {"recordType":"path","path":"channels.telegram.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*.actions.createForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.deleteMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.accounts.*.actions.editForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.editMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.poll","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2883,6 +2886,7 @@ {"recordType":"path","path":"channels.telegram.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.actions.createForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.deleteMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.actions.editForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.editMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.poll","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3940,11 +3944,31 @@ {"recordType":"path","path":"plugins.entries.acpx.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable ACPX Runtime","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider","help":"OpenClaw Anthropic provider plugin (plugin: anthropic)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider Config","help":"Plugin-defined config payload for anthropic.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/anthropic-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles","help":"OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles Config","help":"Plugin-defined config payload for bluebubbles.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/bluebubbles","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider","help":"OpenClaw BytePlus provider plugin (plugin: byteplus)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider Config","help":"Plugin-defined config payload for byteplus.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/byteplus-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy","help":"OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy Config","help":"Plugin-defined config payload for copilot-proxy.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/copilot-proxy","hasChildren":false} @@ -3998,16 +4022,26 @@ {"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-gemini-cli-auth","help":"OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)","hasChildren":true} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-gemini-cli-auth Config","help":"Plugin-defined config payload for google-gemini-cli-auth.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-gemini-cli-auth","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider","help":"OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider Config","help":"Plugin-defined config payload for github-copilot.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/github-copilot-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat","help":"OpenClaw Google Chat channel plugin (plugin: googlechat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat Config","help":"Plugin-defined config payload for googlechat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/googlechat","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider","help":"OpenClaw Hugging Face provider plugin (plugin: huggingface)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider Config","help":"Plugin-defined config payload for huggingface.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/huggingface-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage","help":"OpenClaw iMessage channel plugin (plugin: imessage)","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage Config","help":"Plugin-defined config payload for imessage.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/imessage","hasChildren":false} @@ -4018,6 +4052,16 @@ {"recordType":"path","path":"plugins.entries.irc.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/irc","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider","help":"OpenClaw Kilo Gateway provider plugin (plugin: kilocode)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider Config","help":"Plugin-defined config payload for kilocode.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kilocode-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider","help":"OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi-coding.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider Config","help":"Plugin-defined config payload for kimi-coding.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kimi-coding-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi-coding.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line","help":"OpenClaw LINE channel plugin (plugin: line)","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line Config","help":"Plugin-defined config payload for line.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/line","hasChildren":false} @@ -4069,11 +4113,26 @@ {"recordType":"path","path":"plugins.entries.memory-lancedb.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Enable @openclaw/memory-lancedb","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-portal-auth","help":"OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)","hasChildren":true} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-portal-auth Config","help":"Plugin-defined config payload for minimax-portal-auth.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-portal-auth","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider","help":"OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider Config","help":"Plugin-defined config payload for minimax.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider","help":"OpenClaw Mistral provider plugin (plugin: mistral)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider Config","help":"Plugin-defined config payload for mistral.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mistral-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider","help":"OpenClaw Model Studio provider plugin (plugin: modelstudio)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider Config","help":"Plugin-defined config payload for modelstudio.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/modelstudio-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams","help":"OpenClaw Microsoft Teams channel plugin (plugin: msteams)","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams Config","help":"Plugin-defined config payload for msteams.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/msteams","hasChildren":false} @@ -4089,6 +4148,11 @@ {"recordType":"path","path":"plugins.entries.nostr.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nostr","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider","help":"OpenClaw NVIDIA provider plugin (plugin: nvidia)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider Config","help":"Plugin-defined config payload for nvidia.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nvidia-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider","help":"OpenClaw Ollama provider plugin (plugin: ollama)","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider Config","help":"Plugin-defined config payload for ollama.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/ollama-provider","hasChildren":false} @@ -4099,11 +4163,58 @@ {"recordType":"path","path":"plugins.entries.open-prose.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenProse","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider","help":"OpenClaw OpenAI provider plugins (plugin: openai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider Config","help":"Plugin-defined config payload for openai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openai-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider","help":"OpenClaw OpenCode Zen provider plugin (plugin: opencode)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider","help":"OpenClaw OpenCode Go provider plugin (plugin: opencode-go)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider Config","help":"Plugin-defined config payload for opencode-go.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-go-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider Config","help":"Plugin-defined config payload for opencode.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider","help":"OpenClaw OpenRouter provider plugin (plugin: openrouter)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider Config","help":"Plugin-defined config payload for openrouter.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openrouter-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox","help":"Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox Config","help":"Plugin-defined config payload for openshell.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config.autoProviders","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Auto-create Providers","help":"When enabled, pass --auto-providers during sandbox create.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.command","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Command","help":"Path or command name for the openshell CLI.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.from","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Sandbox Source","help":"OpenShell sandbox source for first-time create. Defaults to openclaw.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gateway","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway Name","help":"Optional OpenShell gateway name passed as --gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gatewayEndpoint","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway Endpoint","help":"Optional OpenShell gateway endpoint passed as --gateway-endpoint.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gpu","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"GPU","help":"Request GPU resources when creating the sandbox.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.policy","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Policy File","help":"Optional path to a custom OpenShell sandbox policy YAML.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.providers","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Providers","help":"Provider names to attach when a sandbox is created.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config.providers.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.remoteAgentWorkspaceDir","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","storage"],"label":"Remote Agent Dir","help":"Mirror path for the real agent workspace when workspaceAccess is read-only.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.remoteWorkspaceDir","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","storage"],"label":"Remote Workspace Dir","help":"Primary writable workspace inside the OpenShell sandbox.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.timeoutSeconds","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","performance"],"label":"Command Timeout Seconds","help":"Timeout for openshell CLI operations such as create/upload/download.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenShell Sandbox","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control","help":"Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control Config","help":"Plugin-defined config payload for phone-control.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Phone Control","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider","help":"OpenClaw Qianfan provider plugin (plugin: qianfan)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider Config","help":"Plugin-defined config payload for qianfan.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/qianfan-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth","help":"Plugin entry for qwen-portal-auth.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth Config","help":"Plugin-defined config payload for qwen-portal-auth.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable qwen-portal-auth","hasChildren":false} @@ -4129,6 +4240,11 @@ {"recordType":"path","path":"plugins.entries.synology-chat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synology-chat","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider","help":"OpenClaw Synthetic provider plugin (plugin: synthetic)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider Config","help":"Plugin-defined config payload for synthetic.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synthetic-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice","help":"Manage Talk voice selection (list/set). (plugin: talk-voice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice Config","help":"Plugin-defined config payload for talk-voice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Talk Voice","hasChildren":false} @@ -4152,11 +4268,26 @@ {"recordType":"path","path":"plugins.entries.tlon.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tlon","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider","help":"OpenClaw Together provider plugin (plugin: together)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider Config","help":"Plugin-defined config payload for together.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/together-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch","help":"OpenClaw Twitch channel plugin (plugin: twitch)","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch Config","help":"Plugin-defined config payload for twitch.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/twitch","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider","help":"OpenClaw Venice provider plugin (plugin: venice)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider Config","help":"Plugin-defined config payload for venice.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/venice-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider","help":"OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider Config","help":"Plugin-defined config payload for vercel-ai-gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vercel-ai-gateway-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider","help":"OpenClaw vLLM provider plugin (plugin: vllm)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider Config","help":"Plugin-defined config payload for vllm.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vllm-provider","hasChildren":false} @@ -4283,11 +4414,31 @@ {"recordType":"path","path":"plugins.entries.voice-call.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/voice-call","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider","help":"OpenClaw Volcengine provider plugin (plugin: volcengine)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider Config","help":"Plugin-defined config payload for volcengine.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/volcengine-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp","help":"OpenClaw WhatsApp channel plugin (plugin: whatsapp)","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp Config","help":"Plugin-defined config payload for whatsapp.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/whatsapp","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider","help":"OpenClaw Xiaomi provider plugin (plugin: xiaomi)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider Config","help":"Plugin-defined config payload for xiaomi.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xiaomi-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider","help":"OpenClaw Z.AI provider plugin (plugin: zai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider Config","help":"Plugin-defined config payload for zai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zai-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo","help":"OpenClaw Zalo channel plugin (plugin: zalo)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo Config","help":"Plugin-defined config payload for zalo.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalo","hasChildren":false} From 0218045818ec951bf58b9052707a783aee8a0c6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:02:25 -0700 Subject: [PATCH 100/331] test: silence vitest warning noise --- src/cli/program.test-mocks.ts | 164 +++++++++++++++++------------ src/infra/warning-filter.test.ts | 9 ++ src/plugins/loader.test.ts | 14 +-- ui/src/i18n/lib/translate.ts | 9 +- ui/src/i18n/test/translate.test.ts | 16 +++ ui/src/local-storage.ts | 25 +++++ ui/src/ui/app-render.ts | 5 +- ui/src/ui/chat/deleted-messages.ts | 6 +- ui/src/ui/chat/grouped-render.ts | 5 +- ui/src/ui/chat/pinned-messages.ts | 6 +- ui/src/ui/controllers/usage.ts | 10 +- ui/src/ui/device-auth.ts | 5 +- ui/src/ui/device-identity.ts | 8 +- ui/src/ui/storage.ts | 9 +- ui/src/ui/views/chat.test.ts | 5 +- 15 files changed, 190 insertions(+), 106 deletions(-) create mode 100644 ui/src/local-storage.ts diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index ab0d6b497bf..cf71122749f 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -1,78 +1,104 @@ -import { Mock, vi } from "vitest"; +import { vi, type Mock } from "vitest"; -export const messageCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const statusCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommandWithSections: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const setupCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const onboardCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const callGateway: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogin: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogout: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runTui: Mock<(...args: unknown[]) => unknown> = vi.fn(); +type AnyMock = Mock<(...args: unknown[]) => unknown>; -export const loadAndMaybeMigrateDoctorConfig: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensureConfigReady: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensurePluginRegistryLoaded: Mock<(...args: unknown[]) => unknown> = vi.fn(); +const programMocks = vi.hoisted(() => ({ + messageCommand: vi.fn(), + statusCommand: vi.fn(), + configureCommand: vi.fn(), + configureCommandWithSections: vi.fn(), + setupCommand: vi.fn(), + onboardCommand: vi.fn(), + callGateway: vi.fn(), + runChannelLogin: vi.fn(), + runChannelLogout: vi.fn(), + runTui: vi.fn(), + loadAndMaybeMigrateDoctorConfig: vi.fn(), + ensureConfigReady: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }, +})); -export const runtime: { +export const messageCommand = programMocks.messageCommand as AnyMock; +export const statusCommand = programMocks.statusCommand as AnyMock; +export const configureCommand = programMocks.configureCommand as AnyMock; +export const configureCommandWithSections = programMocks.configureCommandWithSections as AnyMock; +export const setupCommand = programMocks.setupCommand as AnyMock; +export const onboardCommand = programMocks.onboardCommand as AnyMock; +export const callGateway = programMocks.callGateway as AnyMock; +export const runChannelLogin = programMocks.runChannelLogin as AnyMock; +export const runChannelLogout = programMocks.runChannelLogout as AnyMock; +export const runTui = programMocks.runTui as AnyMock; +export const loadAndMaybeMigrateDoctorConfig = + programMocks.loadAndMaybeMigrateDoctorConfig as AnyMock; +export const ensureConfigReady = programMocks.ensureConfigReady as AnyMock; +export const ensurePluginRegistryLoaded = programMocks.ensurePluginRegistryLoaded as AnyMock; + +export const runtime = programMocks.runtime as { log: Mock<(...args: unknown[]) => void>; error: Mock<(...args: unknown[]) => void>; exit: Mock<(...args: unknown[]) => never>; -} = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), }; -export function installBaseProgramMocks() { - vi.mock("../commands/message.js", () => ({ messageCommand })); - vi.mock("../commands/status.js", () => ({ statusCommand })); - vi.mock("../commands/configure.js", () => ({ - CONFIGURE_WIZARD_SECTIONS: [ - "workspace", - "model", - "web", - "gateway", - "daemon", - "channels", - "skills", - "health", - ], - configureCommand, - configureCommandWithSections, - configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { - const resolved = Array.isArray(sections) ? sections : []; - if (resolved.length > 0) { - return configureCommandWithSections(resolved, runtime); - } - return configureCommand({}, runtime); - }, - })); - vi.mock("../commands/setup.js", () => ({ setupCommand })); - vi.mock("../commands/onboard.js", () => ({ onboardCommand })); - vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); - vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout })); - vi.mock("../tui/tui.js", () => ({ runTui })); - vi.mock("../gateway/call.js", () => ({ - callGateway, - randomIdempotencyKey: () => "idem-test", - buildGatewayConnectionDetails: () => ({ - url: "ws://127.0.0.1:1234", - urlSource: "test", - message: "Gateway target: ws://127.0.0.1:1234", - }), - })); - vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); -} +// Keep these mocks at top level so Vitest does not warn about hoisted nested mocks. +vi.mock("../commands/message.js", () => ({ messageCommand: programMocks.messageCommand })); +vi.mock("../commands/status.js", () => ({ statusCommand: programMocks.statusCommand })); +vi.mock("../commands/configure.js", () => ({ + CONFIGURE_WIZARD_SECTIONS: [ + "workspace", + "model", + "web", + "gateway", + "daemon", + "channels", + "skills", + "health", + ], + configureCommand: programMocks.configureCommand, + configureCommandWithSections: programMocks.configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return programMocks.configureCommandWithSections(resolved, runtime); + } + return programMocks.configureCommand({}, runtime); + }, +})); +vi.mock("../commands/setup.js", () => ({ setupCommand: programMocks.setupCommand })); +vi.mock("../commands/onboard.js", () => ({ onboardCommand: programMocks.onboardCommand })); +vi.mock("../runtime.js", () => ({ defaultRuntime: programMocks.runtime })); +vi.mock("./channel-auth.js", () => ({ + runChannelLogin: programMocks.runChannelLogin, + runChannelLogout: programMocks.runChannelLogout, +})); +vi.mock("../tui/tui.js", () => ({ runTui: programMocks.runTui })); +vi.mock("../gateway/call.js", () => ({ + callGateway: programMocks.callGateway, + randomIdempotencyKey: () => "idem-test", + buildGatewayConnectionDetails: () => ({ + url: "ws://127.0.0.1:1234", + urlSource: "test", + message: "Gateway target: ws://127.0.0.1:1234", + }), +})); +vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); +vi.mock("./plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: programMocks.ensurePluginRegistryLoaded, +})); +vi.mock("../commands/doctor-config-flow.js", () => ({ + loadAndMaybeMigrateDoctorConfig: programMocks.loadAndMaybeMigrateDoctorConfig, +})); +vi.mock("./program/config-guard.js", () => ({ + ensureConfigReady: programMocks.ensureConfigReady, +})); +vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -export function installSmokeProgramMocks() { - vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded })); - vi.mock("../commands/doctor-config-flow.js", () => ({ - loadAndMaybeMigrateDoctorConfig, - })); - vi.mock("./program/config-guard.js", () => ({ ensureConfigReady })); - vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -} +export function installBaseProgramMocks() {} + +export function installSmokeProgramMocks() {} diff --git a/src/infra/warning-filter.test.ts b/src/infra/warning-filter.test.ts index 7ce9854aa9a..da4b9dad163 100644 --- a/src/infra/warning-filter.test.ts +++ b/src/infra/warning-filter.test.ts @@ -74,6 +74,7 @@ describe("warning filter", () => { it("installs once and suppresses known warnings at emit time", async () => { const seenWarnings: Array<{ code?: string; name: string; message: string }> = []; + const stderrWrites: string[] = []; const onWarning = (warning: Error & { code?: string }) => { seenWarnings.push({ code: warning.code, @@ -81,6 +82,12 @@ describe("warning filter", () => { message: warning.message, }); }; + const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write); process.on("warning", onWarning); try { @@ -135,7 +142,9 @@ describe("warning filter", () => { warning.code === "DEP0040" && warning.message === "The punycode module is deprecated.", ), ).toBeDefined(); + expect(stderrWrites.join("")).toContain("Visible warning"); } finally { + stderrWriteSpy.mockRestore(); process.off("warning", onWarning); } }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 45710ef08bf..d442685a3ff 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -7,13 +7,13 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { vi.resetModules(); - vi.unmock("node:fs"); - vi.unmock("node:fs/promises"); - vi.unmock("node:module"); - vi.unmock("./hook-runner-global.js"); - vi.unmock("./hooks.js"); - vi.unmock("./loader.js"); - vi.unmock("jiti"); + vi.doUnmock("node:fs"); + vi.doUnmock("node:fs/promises"); + vi.doUnmock("node:module"); + vi.doUnmock("./hook-runner-global.js"); + vi.doUnmock("./hooks.js"); + vi.doUnmock("./loader.js"); + vi.doUnmock("jiti"); const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ import("./loader.js"), import("./hook-runner-global.js"), diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index fc18f36c8e5..11759bc6d8d 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import { en } from "../locales/en.ts"; import { DEFAULT_LOCALE, @@ -22,8 +23,8 @@ class I18nManager { } private readStoredLocale(): string | null { - const storage = globalThis.localStorage; - if (!storage || typeof storage.getItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return null; } try { @@ -34,8 +35,8 @@ class I18nManager { } private persistLocale(locale: Locale) { - const storage = globalThis.localStorage; - if (!storage || typeof storage.setItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return; } try { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index d373d3a47c9..14344b9079b 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -92,6 +92,22 @@ describe("i18n", () => { expect(fresh.t("common.health")).toBe("健康状况"); }); + it("skips node localStorage accessors that warn without a storage file", async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + const warningSpy = vi.spyOn(process, "emitWarning"); + + const fresh = await import("../lib/translate.ts"); + + expect(fresh.i18n.getLocale()).toBe("en"); + expect(warningSpy).not.toHaveBeenCalledWith( + "`--localstorage-file` was provided without a valid path", + expect.anything(), + expect.anything(), + ); + }); + it("keeps the version label available in shipped locales", () => { expect((pt_BR.common as { version?: string }).version).toBeTruthy(); expect((zh_CN.common as { version?: string }).version).toBeTruthy(); diff --git a/ui/src/local-storage.ts b/ui/src/local-storage.ts new file mode 100644 index 00000000000..a1e80d9d32a --- /dev/null +++ b/ui/src/local-storage.ts @@ -0,0 +1,25 @@ +function isStorage(value: unknown): value is Storage { + return ( + Boolean(value) && + typeof (value as Storage).getItem === "function" && + typeof (value as Storage).setItem === "function" + ); +} + +export function getSafeLocalStorage(): Storage | null { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + + if (process.env.VITEST) { + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; + } + + if (typeof window !== "undefined" && typeof document !== "undefined") { + try { + return isStorage(window.localStorage) ? window.localStorage : null; + } catch { + return null; + } + } + + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 328f2cb6e33..11bcacae1ee 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -4,6 +4,7 @@ import { parseAgentSessionKey, } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; +import { getSafeLocalStorage } from "../local-storage.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -181,7 +182,7 @@ type DismissedUpdateBanner = { function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { try { - const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); if (!raw) { return null; } @@ -225,7 +226,7 @@ function dismissUpdateBanner(updateAvailable: unknown) { dismissedAtMs: Date.now(), }; try { - localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + getSafeLocalStorage()?.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); } catch { // ignore } diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts index 21094bb9e83..316b659baa8 100644 --- a/ui/src/ui/chat/deleted-messages.ts +++ b/ui/src/ui/chat/deleted-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:deleted:"; export class DeletedMessages { @@ -30,7 +32,7 @@ export class DeletedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -45,7 +47,7 @@ export class DeletedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._keys])); } catch { // ignore } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 5b7549c8d64..7dcc0b62e19 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { AssistantIdentity } from "../assistant-identity.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -322,7 +323,7 @@ type DeleteConfirmSide = "left" | "right"; function shouldSkipDeleteConfirm(): boolean { try { - return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; + return getSafeLocalStorage()?.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; } catch { return false; } @@ -370,7 +371,7 @@ function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { yes.addEventListener("click", () => { if (check.checked) { try { - localStorage.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); + getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); } catch {} } popover.remove(); diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts index a3e77a9483b..3bd7b9d6603 100644 --- a/ui/src/ui/chat/pinned-messages.ts +++ b/ui/src/ui/chat/pinned-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:pinned:"; export class PinnedMessages { @@ -42,7 +44,7 @@ export class PinnedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -57,7 +59,7 @@ export class PinnedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._indices])); } catch { // ignore } diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 0fe257ae8e7..5862bd82e72 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; import type { SessionLogEntry } from "../views/usage.ts"; @@ -39,14 +40,7 @@ const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i; let legacyUsageDateParamsCache: Set | null = null; function getLocalStorage(): Storage | null { - // Support browser runtime and node tests (when localStorage is stubbed globally). - if (typeof window !== "undefined" && window.localStorage) { - return window.localStorage; - } - if (typeof localStorage !== "undefined") { - return localStorage; - } - return null; + return getSafeLocalStorage(); } function loadLegacyUsageDateParamsCache(): Set { diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts index 1adcf7deda9..1238a859f1c 100644 --- a/ui/src/ui/device-auth.ts +++ b/ui/src/ui/device-auth.ts @@ -5,12 +5,13 @@ import { storeDeviceAuthTokenInStore, } from "../../../src/shared/device-auth-store.js"; import type { DeviceAuthStore } from "../../../src/shared/device-auth.js"; +import { getSafeLocalStorage } from "../local-storage.ts"; const STORAGE_KEY = "openclaw.device.auth.v1"; function readStore(): DeviceAuthStore | null { try { - const raw = window.localStorage.getItem(STORAGE_KEY); + const raw = getSafeLocalStorage()?.getItem(STORAGE_KEY); if (!raw) { return null; } @@ -32,7 +33,7 @@ function readStore(): DeviceAuthStore | null { function writeStore(store: DeviceAuthStore) { try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store)); } catch { // best-effort } diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts index 947b8185038..ff20c68649e 100644 --- a/ui/src/ui/device-identity.ts +++ b/ui/src/ui/device-identity.ts @@ -1,4 +1,5 @@ import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; +import { getSafeLocalStorage } from "../local-storage.ts"; type StoredIdentity = { version: 1; @@ -58,8 +59,9 @@ async function generateIdentity(): Promise { } export async function loadOrCreateDeviceIdentity(): Promise { + const storage = getSafeLocalStorage(); try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = storage?.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as StoredIdentity; if ( @@ -74,7 +76,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { ...parsed, deviceId: derivedId, }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + storage?.setItem(STORAGE_KEY, JSON.stringify(updated)); return { deviceId: derivedId, publicKey: parsed.publicKey, @@ -100,7 +102,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { privateKey: identity.privateKey, createdAtMs: Date.now(), }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + storage?.setItem(STORAGE_KEY, JSON.stringify(stored)); return identity; } diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 450c5124592..0b23b3436a4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -16,6 +16,7 @@ type PersistedUiSettings = Omit = {}; try { - const raw = localStorage.getItem(KEY); + const raw = storage?.getItem(KEY); if (raw) { const parsed = JSON.parse(raw) as PersistedUiSettings; if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { @@ -291,5 +294,5 @@ function persistSettings(next: UiSettings) { sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; - localStorage.setItem(KEY, JSON.stringify(persisted)); + storage?.setItem(KEY, JSON.stringify(persisted)); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 860727c1927..ab55db6935f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2,6 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import { renderChatSessionSelect } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -482,7 +483,7 @@ describe("chat view", () => { it("opens delete confirm on the left for user messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ } @@ -515,7 +516,7 @@ describe("chat view", () => { it("opens delete confirm on the right for assistant messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ } From 350b42d3424e216d384744ac7fdd855d128fce97 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:01:31 -0700 Subject: [PATCH 101/331] Status: lazy-load text scan helpers --- src/commands/status.scan.runtime.ts | 2 ++ src/commands/status.scan.test.ts | 5 +++++ src/commands/status.scan.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 src/commands/status.scan.runtime.ts diff --git a/src/commands/status.scan.runtime.ts b/src/commands/status.scan.runtime.ts new file mode 100644 index 00000000000..372b31f4803 --- /dev/null +++ b/src/commands/status.scan.runtime.ts @@ -0,0 +1,2 @@ +export { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; +export { buildChannelsTable } from "./status-all/channels.js"; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 122e10076bf..7dccbefb621 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -30,6 +30,11 @@ vi.mock("./status-all/channels.js", () => ({ buildChannelsTable: mocks.buildChannelsTable, })); +vi.mock("./status.scan.runtime.js", () => ({ + buildChannelsTable: mocks.buildChannelsTable, + collectChannelStatusIssues: vi.fn(() => []), +})); + vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult, })); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 7f1380964d5..64a17e2b371 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -7,14 +7,12 @@ import { readBestEffortConfig } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; -import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; import { getTailnetHostname } from "../infra/tailscale.js"; import { getMemorySearchManager } from "../memory/index.js"; import type { MemoryProviderStatus } from "../memory/types.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; -import { buildChannelsTable } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; import { pickGatewaySelfPresence, @@ -48,12 +46,18 @@ type GatewayProbeSnapshot = { }; let pluginRegistryModulePromise: Promise | undefined; +let statusScanRuntimeModulePromise: Promise | undefined; function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; } +function loadStatusScanRuntimeModule() { + statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); + return statusScanRuntimeModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -360,6 +364,8 @@ export async function scanStatus( progress.setLabel("Querying channel status…"); const channelsStatus = await resolveChannelsStatus({ cfg, gatewayReachable, opts }); + const { collectChannelStatusIssues, buildChannelsTable } = + await loadStatusScanRuntimeModule(); const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; progress.tick(); From 53ccc78c636322f2b17649e83e67862d913dda9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:06:55 -0700 Subject: [PATCH 102/331] refactor: rename setup helper surfaces --- extensions/feishu/src/onboarding.ts | 7 ------- ...ng.status.test.ts => setup-status.test.ts} | 0 ...boarding.test.ts => setup-surface.test.ts} | 0 ...boarding.test.ts => setup-surface.test.ts} | 2 +- ...boarding.test.ts => setup-surface.test.ts} | 0 ...ng.status.test.ts => setup-status.test.ts} | 0 .../plugins/setup-flow-helpers.test.ts | 2 +- src/channels/plugins/setup-flow-helpers.ts | 2 +- src/channels/plugins/setup-flow-types.ts | 4 ++-- src/commands/channels/add.ts | 4 ++-- src/commands/onboard-channels.ts | 20 +++++++++---------- src/plugin-sdk/{onboarding.ts => setup.ts} | 0 12 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 extensions/feishu/src/onboarding.ts rename extensions/feishu/src/{onboarding.status.test.ts => setup-status.test.ts} (100%) rename extensions/feishu/src/{onboarding.test.ts => setup-surface.test.ts} (100%) rename extensions/irc/src/{onboarding.test.ts => setup-surface.test.ts} (98%) rename extensions/whatsapp/src/{onboarding.test.ts => setup-surface.test.ts} (100%) rename extensions/zalo/src/{onboarding.status.test.ts => setup-status.test.ts} (100%) rename src/plugin-sdk/{onboarding.ts => setup.ts} (100%) diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts deleted file mode 100644 index ae247b30f76..00000000000 --- a/extensions/feishu/src/onboarding.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { feishuPlugin } from "./channel.js"; - -export const feishuOnboardingAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ - plugin: feishuPlugin, - wizard: feishuPlugin.setupWizard!, -}); diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/setup-status.test.ts similarity index 100% rename from extensions/feishu/src/onboarding.status.test.ts rename to extensions/feishu/src/setup-status.test.ts diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/setup-surface.test.ts similarity index 100% rename from extensions/feishu/src/onboarding.test.ts rename to extensions/feishu/src/setup-surface.test.ts diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/setup-surface.test.ts similarity index 98% rename from extensions/irc/src/onboarding.test.ts rename to extensions/irc/src/setup-surface.test.ts index 883f15fe1b1..92cca5f0f35 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/setup-surface.test.ts @@ -33,7 +33,7 @@ const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ }); describe("irc setup wizard", () => { - it("configures host and nick via onboarding prompts", async () => { + it("configures host and nick via setup prompts", async () => { const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC server host") { diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/setup-surface.test.ts similarity index 100% rename from extensions/whatsapp/src/onboarding.test.ts rename to extensions/whatsapp/src/setup-surface.test.ts diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/setup-status.test.ts similarity index 100% rename from extensions/zalo/src/onboarding.status.test.ts rename to extensions/zalo/src/setup-status.test.ts diff --git a/src/channels/plugins/setup-flow-helpers.test.ts b/src/channels/plugins/setup-flow-helpers.test.ts index 3b24600372c..d13ce6a3b6b 100644 --- a/src/channels/plugins/setup-flow-helpers.test.ts +++ b/src/channels/plugins/setup-flow-helpers.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); -vi.mock("../../plugin-sdk/onboarding.js", () => ({ +vi.mock("../../plugin-sdk/setup.js", () => ({ promptAccountId: promptAccountIdSdkMock, })); diff --git a/src/channels/plugins/setup-flow-helpers.ts b/src/channels/plugins/setup-flow-helpers.ts index 87a208a9a21..b0519b8f35d 100644 --- a/src/channels/plugins/setup-flow-helpers.ts +++ b/src/channels/plugins/setup-flow-helpers.ts @@ -5,7 +5,7 @@ import { import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; -import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/onboarding.js"; +import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/setup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { PromptAccountId, PromptAccountIdParams } from "./setup-flow-types.js"; diff --git a/src/channels/plugins/setup-flow-types.ts b/src/channels/plugins/setup-flow-types.ts index a3887cc7ef2..53766d72af6 100644 --- a/src/channels/plugins/setup-flow-types.ts +++ b/src/channels/plugins/setup-flow-types.ts @@ -4,7 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; -export type ChannelOnboardingSetupPlugin = Pick< +export type ChannelSetupPlugin = Pick< ChannelPlugin, "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" >; @@ -15,7 +15,7 @@ export type SetupChannelsOptions = { onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; - onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index d4175cf100b..b4f8205ae3a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,7 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; +import type { ChannelSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; @@ -57,7 +57,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; - const resolvedPlugins = new Map(); + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 67c78e7a72c..564e056b053 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/setup-flow-types.js"; +import type { ChannelSetupPlugin } from "../channels/plugins/setup-flow-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, @@ -91,7 +91,7 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; - plugin?: ChannelOnboardingSetupPlugin; + plugin?: ChannelSetupPlugin; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = params.plugin ?? getChannelSetupPlugin(channel); @@ -118,7 +118,7 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ChannelOnboardingSetupPlugin[]; + installedPlugins?: ChannelSetupPlugin[]; resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); @@ -347,19 +347,17 @@ export async function setupChannels( const accountOverrides: Partial> = { ...options?.accountIds, }; - const scopedPluginsById = new Map(); + const scopedPluginsById = new Map(); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => { + const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; - const getVisibleChannelPlugin = ( - channel: ChannelChoice, - ): ChannelOnboardingSetupPlugin | undefined => + const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => { - const merged = new Map(); + const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { + const merged = new Map(); for (const plugin of listChannelSetupPlugins()) { merged.set(plugin.id, plugin); } @@ -371,7 +369,7 @@ export async function setupChannels( const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; diff --git a/src/plugin-sdk/onboarding.ts b/src/plugin-sdk/setup.ts similarity index 100% rename from src/plugin-sdk/onboarding.ts rename to src/plugin-sdk/setup.ts From 0f43dc46808bca5cd9ff355469030ad322c6044e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:07:05 -0700 Subject: [PATCH 103/331] test: fix fetch mock typing --- extensions/msteams/src/graph-upload.test.ts | 7 +++--- src/agents/model-auth.test.ts | 23 +++++++++++-------- src/infra/fetch.test.ts | 2 +- src/infra/provider-usage.fetch.shared.test.ts | 5 ++-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 484075984dd..b79086f54ca 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; describe("graph upload helpers", () => { @@ -22,7 +23,7 @@ describe("graph upload helpers", () => { buffer: Buffer.from("hello"), filename: "a.txt", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }); expect(fetchFn).toHaveBeenCalledWith( @@ -59,7 +60,7 @@ describe("graph upload helpers", () => { filename: "b.txt", siteId: "site-123", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }); expect(fetchFn).toHaveBeenCalledWith( @@ -94,7 +95,7 @@ describe("graph upload helpers", () => { filename: "bad.txt", siteId: "site-123", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }), ).rejects.toThrow("SharePoint upload response missing required fields"); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index de8f0f1b752..31fdee5496c 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1,5 +1,6 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { @@ -503,16 +504,18 @@ describe("applyLocalNoAuthHeaderOverride", () => { const requestSeen = new Promise((resolve) => { resolveRequest = resolve; }); - globalThis.fetch = vi.fn(async (_input, init) => { - const headers = new Headers(init?.headers); - capturedAuthorization = headers.get("Authorization"); - capturedXTest = headers.get("X-Test"); - resolveRequest?.(); - return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; + globalThis.fetch = withFetchPreconnect( + vi.fn(async (_input, init) => { + const headers = new Headers(init?.headers); + capturedAuthorization = headers.get("Authorization"); + capturedXTest = headers.get("X-Test"); + resolveRequest?.(); + return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }), + ); const model = applyLocalNoAuthHeaderOverride( { diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts index deef81f551f..820325c0e70 100644 --- a/src/infra/fetch.test.ts +++ b/src/infra/fetch.test.ts @@ -293,7 +293,7 @@ describe("wrapFetchWithAbortSignal", () => { }); it("exposes a no-op preconnect when the source fetch has none", () => { - const fetchImpl = vi.fn(async () => ({ ok: true }) as Response) as typeof fetch; + const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response)); const wrapped = wrapFetchWithAbortSignal(fetchImpl) as typeof fetch & { preconnect: (url: string, init?: { credentials?: RequestCredentials }) => unknown; }; diff --git a/src/infra/provider-usage.fetch.shared.test.ts b/src/infra/provider-usage.fetch.shared.test.ts index 692a57705db..b287f1fad04 100644 --- a/src/infra/provider-usage.fetch.shared.test.ts +++ b/src/infra/provider-usage.fetch.shared.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import { buildUsageErrorSnapshot, buildUsageHttpErrorSnapshot, @@ -36,7 +37,7 @@ describe("provider usage fetch shared helpers", () => { async (_input: URL | RequestInfo, init?: RequestInit) => new Response(JSON.stringify({ aborted: init?.signal?.aborted ?? false }), { status: 200 }), ); - const fetchFn = fetchFnMock as typeof fetch; + const fetchFn = withFetchPreconnect(fetchFnMock); const response = await fetchJson( "https://example.com/usage", @@ -71,7 +72,7 @@ describe("provider usage fetch shared helpers", () => { }); }), ); - const fetchFn = fetchFnMock as typeof fetch; + const fetchFn = withFetchPreconnect(fetchFnMock); const request = fetchJson("https://example.com/usage", {}, 50, fetchFn); const rejection = expect(request).rejects.toThrow("aborted by timeout"); From c4a5fd8465cbee23e2f3b6986c16f448763b34ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:07:18 -0700 Subject: [PATCH 104/331] docs: update channel setup wording --- docs/channels/feishu.md | 4 ++-- docs/channels/matrix.md | 4 ++-- docs/channels/mattermost.md | 2 +- docs/channels/msteams.md | 2 +- docs/channels/nextcloud-talk.md | 4 ++-- docs/channels/nostr.md | 2 +- docs/channels/telegram.md | 2 +- docs/channels/whatsapp.md | 2 +- docs/channels/zalo.md | 6 +++--- docs/channels/zalouser.md | 4 ++-- docs/tools/plugin.md | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 2fc16aed5d4..3768906d940 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -30,9 +30,9 @@ openclaw plugins install @openclaw/feishu There are two ways to add the Feishu channel: -### Method 1: onboarding wizard (recommended) +### Method 1: setup wizard (recommended) -If you just installed OpenClaw, run the wizard: +If you just installed OpenClaw, run the setup wizard: ```bash openclaw onboard diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 9bb56d1ddb7..1536a7c08ac 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -31,7 +31,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/matrix ``` -If you choose Matrix during configure/onboarding and a git checkout is detected, +If you choose Matrix during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) @@ -72,7 +72,7 @@ Details: [Plugins](/tools/plugin) - If both are set, config takes precedence. - With access token: user ID is fetched automatically via `/whoami`. - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). -5. Restart the gateway (or finish onboarding). +5. Restart the gateway (or finish setup). 6. Start a DM with the bot or invite it to a room from any Matrix client (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, so set `channels.matrix.encryption: true` and verify the device. diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 1e3e3f4bad2..2ceb6c17626 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -28,7 +28,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/mattermost ``` -If you choose Mattermost during configure/onboarding and a git checkout is detected, +If you choose Mattermost during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index a24f20c69df..88cba3ce6aa 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -33,7 +33,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/msteams ``` -If you choose Teams during configure/onboarding and a git checkout is detected, +If you choose Teams during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index 7797b1276ff..f8be8d74f0c 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -25,7 +25,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/nextcloud-talk ``` -If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected, +If you choose Nextcloud Talk during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) @@ -43,7 +43,7 @@ Details: [Plugins](/tools/plugin) 4. Configure OpenClaw: - Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret` - Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only) -5. Restart the gateway (or finish onboarding). +5. Restart the gateway (or finish setup). Minimal config: diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 760704b589f..46888da0352 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -16,7 +16,7 @@ Nostr is a decentralized protocol for social networking. This channel enables Op ### Onboarding (recommended) -- The onboarding wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. +- The setup wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. - Selecting Nostr prompts you to install the plugin on demand. Install defaults: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 37be3bf1111..b5700213830 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -115,7 +115,7 @@ Token resolution order is account-aware. In practice, config values win over env `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. `dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation. - The onboarding wizard accepts `@username` input and resolves it to numeric IDs. + The setup wizard accepts `@username` input and resolves it to numeric IDs. If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index cad9fe77ee3..850d88ffcac 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -76,7 +76,7 @@ openclaw pairing approve whatsapp -OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.) +OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and setup flow are optimized for that setup, but personal-number setups are also supported.) ## Deployment patterns diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cf53b574e42..b327f596f74 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -14,7 +14,7 @@ Status: experimental. DMs are supported. The [Capabilities](#capabilities) secti Zalo ships as a plugin and is not bundled with the core install. - Install via CLI: `openclaw plugins install @openclaw/zalo` -- Or select **Zalo** during onboarding and confirm the install prompt +- Or select **Zalo** during setup and confirm the install prompt - Details: [Plugins](/tools/plugin) ## Quick setup (beginner) @@ -22,11 +22,11 @@ Zalo ships as a plugin and is not bundled with the core install. 1. Install the Zalo plugin: - From a source checkout: `openclaw plugins install ./extensions/zalo` - From npm (if published): `openclaw plugins install @openclaw/zalo` - - Or pick **Zalo** in onboarding and confirm the install prompt + - Or pick **Zalo** in setup and confirm the install prompt 2. Set the token: - Env: `ZALO_BOT_TOKEN=...` - Or config: `channels.zalo.accounts.default.botToken: "..."`. -3. Restart the gateway (or finish onboarding). +3. Restart the gateway (or finish setup). 4. DM access is pairing by default; approve the pairing code on first contact. Minimal config: diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 58bd2a43923..4847430c8ac 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -41,7 +41,7 @@ No external `zca`/`openzca` CLI binary is required. } ``` -4. Restart the Gateway (or finish onboarding). +4. Restart the Gateway (or finish setup). 5. DM access defaults to pairing; approve the pairing code on first contact. ## What it is @@ -74,7 +74,7 @@ openclaw directory groups list --channel zalouser --query "work" `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). -`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup. +`channels.zalouser.allowFrom` accepts user IDs or names. During setup, names are resolved to IDs using the plugin's in-process contact lookup. Approve via: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 62350fb9dd4..8be0743c57c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -800,7 +800,7 @@ trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. When OpenClaw needs setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but still unconfigured, it loads `setupEntry` -instead of the full plugin entry. This keeps startup and onboarding lighter +instead of the full plugin entry. This keeps startup and setup lighter when your main plugin entry also wires tools, hooks, or other runtime-only code. From 093e51f2b35b90d6ed6b8ea808835ab30dac98e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:08:41 -0700 Subject: [PATCH 105/331] Security: lazy-load channel audit provider helpers --- src/security/audit-channel.runtime.ts | 9 ++++ src/security/audit-channel.ts | 77 ++++++++++++++++----------- 2 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 src/security/audit-channel.runtime.ts diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts new file mode 100644 index 00000000000..147f686862a --- /dev/null +++ b/src/security/audit-channel.runtime.ts @@ -0,0 +1,9 @@ +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; +export { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +export { + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, +} from "./mutable-allowlist-detectors.js"; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ce1484f6513..56f3b139f87 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -1,7 +1,3 @@ -import { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; import { hasConfiguredUnavailableCredentialStatus, hasResolvedCredentialValue, @@ -15,14 +11,18 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../con import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; import { resolveDmAllowState } from "./dm-policy-shared.js"; -import { - isDiscordMutableAllowEntry, - isZalouserMutableGroupEntry, -} from "./mutable-allowlist-detectors.js"; + +let auditChannelRuntimeModulePromise: + | Promise + | undefined; + +function loadAuditChannelRuntimeModule() { + auditChannelRuntimeModulePromise ??= import("./audit-channel.runtime.js"); + return auditChannelRuntimeModulePromise; +} function normalizeAllowFromList(list: Array | undefined | null): string[] { return normalizeStringEntries(Array.isArray(list) ? list : undefined); @@ -32,12 +32,13 @@ function addDiscordNameBasedEntries(params: { target: Set; values: unknown; source: string; + isDiscordMutableAllowEntry: (value: string) => boolean; }): void { if (!Array.isArray(params.values)) { return; } for (const value of params.values) { - if (!isDiscordMutableAllowEntry(String(value))) { + if (!params.isDiscordMutableAllowEntry(String(value))) { continue; } const text = String(value).trim(); @@ -52,25 +53,28 @@ function addZalouserMutableGroupEntries(params: { target: Set; groups: unknown; source: string; + isZalouserMutableGroupEntry: (value: string) => boolean; }): void { if (!params.groups || typeof params.groups !== "object" || Array.isArray(params.groups)) { return; } for (const key of Object.keys(params.groups as Record)) { - if (!isZalouserMutableGroupEntry(key)) { + if (!params.isZalouserMutableGroupEntry(key)) { continue; } params.target.add(`${params.source}:${key}`); } } -function collectInvalidTelegramAllowFromEntries(params: { +async function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set; -}): void { +}): Promise { if (!Array.isArray(params.entries)) { return; } + const { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } = + await loadAuditChannelRuntimeModule(); for (const entry of params.entries) { const normalized = normalizeTelegramAllowFromEntry(entry); if (!normalized || normalized === "*") { @@ -383,6 +387,8 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "discord") { + const { isDiscordMutableAllowEntry, readChannelAllowFromStore } = + await loadAuditChannelRuntimeModule(); const discordCfg = (account as { config?: Record } | null)?.config ?? ({} as Record); @@ -401,16 +407,19 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: discordCfg.allowFrom, source: `${discordPathPrefix}.allowFrom`, + isDiscordMutableAllowEntry, }); addDiscordNameBasedEntries({ target: discordNameBasedAllowEntries, values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom, source: `${discordPathPrefix}.dm.allowFrom`, + isDiscordMutableAllowEntry, }); addDiscordNameBasedEntries({ target: discordNameBasedAllowEntries, values: storeAllowFrom, source: "~/.openclaw/credentials/discord-allowFrom.json", + isDiscordMutableAllowEntry, }); const discordGuildEntries = (discordCfg.guilds as Record | undefined) ?? {}; @@ -423,6 +432,7 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: guild.users, source: `${discordPathPrefix}.guilds.${guildKey}.users`, + isDiscordMutableAllowEntry, }); const channels = guild.channels; if (!channels || typeof channels !== "object") { @@ -439,6 +449,7 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: channel.users, source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`, + isDiscordMutableAllowEntry, }); } } @@ -547,6 +558,7 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "zalouser") { + const { isZalouserMutableGroupEntry } = await loadAuditChannelRuntimeModule(); const zalouserCfg = (account as { config?: Record } | null)?.config ?? ({} as Record); @@ -560,6 +572,7 @@ export async function collectChannelSecurityFindings(params: { target: mutableGroupEntries, groups: zalouserCfg.groups, source: `${zalouserPathPrefix}.groups`, + isZalouserMutableGroupEntry, }); if (mutableGroupEntries.size > 0) { const examples = Array.from(mutableGroupEntries).slice(0, 5); @@ -586,6 +599,7 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "slack") { + const { readChannelAllowFromStore } = await loadAuditChannelRuntimeModule(); const slackCfg = (account as { config?: Record; dm?: Record } | null) ?.config ?? ({} as Record); @@ -724,6 +738,7 @@ export async function collectChannelSecurityFindings(params: { continue; } + const { readChannelAllowFromStore } = await loadAuditChannelRuntimeModule(); const storeAllowFrom = await readChannelAllowFromStore( "telegram", process.env, @@ -731,7 +746,7 @@ export async function collectChannelSecurityFindings(params: { ).catch(() => []); const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*"); const invalidTelegramAllowFromEntries = new Set(); - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: storeAllowFrom, target: invalidTelegramAllowFromEntries, }); @@ -739,48 +754,50 @@ export async function collectChannelSecurityFindings(params: { ? telegramCfg.groupAllowFrom : []; const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*"); - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: groupAllowFrom, target: invalidTelegramAllowFromEntries, }); const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : []; - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: dmAllowFrom, target: invalidTelegramAllowFromEntries, }); - const anyGroupOverride = Boolean( - groups && - Object.values(groups).some((value) => { + let anyGroupOverride = false; + if (groups) { + for (const value of Object.values(groups)) { if (!value || typeof value !== "object") { - return false; + continue; } const group = value as Record; const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : []; if (allowFrom.length > 0) { - collectInvalidTelegramAllowFromEntries({ + anyGroupOverride = true; + await collectInvalidTelegramAllowFromEntries({ entries: allowFrom, target: invalidTelegramAllowFromEntries, }); - return true; } const topics = group.topics; if (!topics || typeof topics !== "object") { - return false; + continue; } - return Object.values(topics as Record).some((topicValue) => { + for (const topicValue of Object.values(topics as Record)) { if (!topicValue || typeof topicValue !== "object") { - return false; + continue; } const topic = topicValue as Record; const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : []; - collectInvalidTelegramAllowFromEntries({ + if (topicAllow.length > 0) { + anyGroupOverride = true; + } + await collectInvalidTelegramAllowFromEntries({ entries: topicAllow, target: invalidTelegramAllowFromEntries, }); - return topicAllow.length > 0; - }); - }), - ); + } + } + } const hasAnySenderAllowlist = storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride; From 7e8f5ca71b7de1490a0db7f627dbc32a76fdee86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 04:13:32 +0000 Subject: [PATCH 106/331] fix(ui): centralize control model ref handling --- ui/src/ui/app-chat.test.ts | 16 +++- ui/src/ui/app-chat.ts | 8 +- ui/src/ui/app-render.helpers.ts | 33 +++---- ui/src/ui/app-view-state.ts | 3 +- ui/src/ui/app.ts | 3 +- ui/src/ui/chat-model-ref.test.ts | 50 ++++++++++ ui/src/ui/chat-model-ref.ts | 93 +++++++++++++++++++ .../chat/slash-command-executor.node.test.ts | 34 ++++++- ui/src/ui/chat/slash-command-executor.ts | 22 ++++- ui/src/ui/types.ts | 9 +- ui/src/ui/views/chat.browser.test.ts | 2 +- ui/src/ui/views/chat.test.ts | 84 ++++++++++++++--- ui/src/ui/views/sessions.test.ts | 2 +- 13 files changed, 310 insertions(+), 49 deletions(-) create mode 100644 ui/src/ui/chat-model-ref.test.ts create mode 100644 ui/src/ui/chat-model-ref.ts diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9a3e86d375d..b0df28cd947 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -83,7 +83,14 @@ describe("handleSendChat", () => { ); const request = vi.fn(async (method: string, _params?: unknown) => { if (method === "sessions.patch") { - return { ok: true, key: "main" }; + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; } if (method === "chat.history") { return { messages: [], thinkingLevel: null }; @@ -93,7 +100,7 @@ describe("handleSendChat", () => { ts: 0, path: "", count: 0, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [], }; } @@ -116,6 +123,9 @@ describe("handleSendChat", () => { key: "main", model: "gpt-5-mini", }); - expect(host.chatModelOverrides.main).toBe("gpt-5-mini"); + expect(host.chatModelOverrides.main).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c877b4c5a5d..ec5f7300000 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -10,7 +10,7 @@ import { loadModels } from "./controllers/models.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; -import type { ModelCatalogEntry } from "./types.ts"; +import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -29,7 +29,7 @@ export type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; updateComplete?: Promise; @@ -308,10 +308,10 @@ async function dispatchSlashCommand( injectCommandResult(host, result.content); } - if (result.sessionPatch && "model" in result.sessionPatch) { + if (result.sessionPatch && "modelOverride" in result.sessionPatch) { host.chatModelOverrides = { ...host.chatModelOverrides, - [targetSessionKey]: result.sessionPatch.model ?? null, + [targetSessionKey]: result.sessionPatch.modelOverride ?? null, }; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 12e239cb50d..e83825ab899 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -6,6 +6,13 @@ import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; @@ -521,8 +528,8 @@ function resolveActiveSessionRow(state: AppViewState) { function resolveModelOverrideValue(state: AppViewState): string { // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. const cached = state.chatModelOverrides[state.sessionKey]; - if (typeof cached === "string") { - return cached.trim(); + if (cached) { + return normalizeChatModelOverrideValue(cached, state.chatModelCatalog ?? []); } // cached === null means explicitly cleared to default. if (cached === null) { @@ -532,21 +539,14 @@ function resolveModelOverrideValue(state: AppViewState): string { // Include provider prefix so the value matches option keys (provider/model). const activeRow = resolveActiveSessionRow(state); if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { - const provider = activeRow.modelProvider?.trim(); - const model = activeRow.model.trim(); - return provider ? `${provider}/${model}` : model; + return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider); } return ""; } function resolveDefaultModelValue(state: AppViewState): string { const defaults = state.sessionsResult?.defaults; - const model = defaults?.model; - if (typeof model !== "string" || !model.trim()) { - return ""; - } - const provider = defaults?.modelProvider?.trim(); - return provider ? `${provider}/${model.trim()}` : model.trim(); + return resolveServerChatModelValue(defaults?.model, defaults?.modelProvider); } function buildChatModelOptions( @@ -570,9 +570,8 @@ function buildChatModelOptions( }; for (const entry of catalog) { - const provider = entry.provider?.trim(); - const value = provider ? `${provider}/${entry.id}` : entry.id; - addOption(value, provider ? `${entry.id} · ${provider}` : entry.id); + const option = buildChatModelOption(entry); + addOption(option.value, option.label); } if (currentOverride) { @@ -592,9 +591,7 @@ function renderChatModelSelect(state: AppViewState) { currentOverride, defaultModel, ); - const defaultDisplay = defaultModel.includes("/") - ? `${defaultModel.slice(defaultModel.indexOf("/") + 1)} · ${defaultModel.slice(0, defaultModel.indexOf("/"))}` - : defaultModel; + const defaultDisplay = formatChatModelDisplay(defaultModel); const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; @@ -639,7 +636,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) { // Write the override cache immediately so the picker stays in sync during the RPC round-trip. state.chatModelOverrides = { ...state.chatModelOverrides, - [targetSessionKey]: nextModel || null, + [targetSessionKey]: createChatModelOverride(nextModel), }; try { await state.client.request("sessions.patch", { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ad2910625b6..375faa43137 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -21,6 +21,7 @@ import type { HealthSummary, LogEntry, LogLevel, + ChatModelOverride, ModelCatalogEntry, NostrProfile, PresenceEntry, @@ -71,7 +72,7 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1b3971a41f6..af0d0cb9c96 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -69,6 +69,7 @@ import type { AgentIdentityResult, ConfigSnapshot, ConfigUiHints, + ChatModelOverride, CronJob, CronRunLogEntry, CronStatus, @@ -158,7 +159,7 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; - @state() chatModelOverrides: Record = {}; + @state() chatModelOverrides: Record = {}; @state() chatModelsLoading = false; @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/ui/chat-model-ref.test.ts new file mode 100644 index 00000000000..86b46f3fe7f --- /dev/null +++ b/ui/src/ui/chat-model-ref.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; +import type { ModelCatalogEntry } from "./types.ts"; + +const catalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" }, +]; + +describe("chat-model-ref helpers", () => { + it("builds provider-qualified option values and labels", () => { + expect(buildChatModelOption(catalog[0])).toEqual({ + value: "openai/gpt-5-mini", + label: "gpt-5-mini · openai", + }); + }); + + it("normalizes raw overrides when the catalog match is unique", () => { + expect(normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), catalog)).toBe( + "openai/gpt-5-mini", + ); + }); + + it("keeps ambiguous raw overrides unchanged", () => { + const ambiguousCatalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openrouter" }, + ]; + + expect( + normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), ambiguousCatalog), + ).toBe("gpt-5-mini"); + }); + + it("formats qualified model refs consistently for default labels", () => { + expect(formatChatModelDisplay("openai/gpt-5-mini")).toBe("gpt-5-mini · openai"); + expect(formatChatModelDisplay("alias-only")).toBe("alias-only"); + }); + + it("resolves server session data to qualified option values", () => { + expect(resolveServerChatModelValue("gpt-5-mini", "openai")).toBe("openai/gpt-5-mini"); + expect(resolveServerChatModelValue("alias-only", null)).toBe("alias-only"); + }); +}); diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/ui/chat-model-ref.ts new file mode 100644 index 00000000000..351b8544bad --- /dev/null +++ b/ui/src/ui/chat-model-ref.ts @@ -0,0 +1,93 @@ +import type { ModelCatalogEntry } from "./types.ts"; + +export type ChatModelOverride = + | { + kind: "qualified"; + value: string; + } + | { + kind: "raw"; + value: string; + }; + +export function buildQualifiedChatModelValue(model: string, provider?: string | null): string { + const trimmedModel = model.trim(); + if (!trimmedModel) { + return ""; + } + const trimmedProvider = provider?.trim(); + return trimmedProvider ? `${trimmedProvider}/${trimmedModel}` : trimmedModel; +} + +export function createChatModelOverride(value: string): ChatModelOverride | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (trimmed.includes("/")) { + return { kind: "qualified", value: trimmed }; + } + return { kind: "raw", value: trimmed }; +} + +export function normalizeChatModelOverrideValue( + override: ChatModelOverride | null | undefined, + catalog: ModelCatalogEntry[], +): string { + if (!override) { + return ""; + } + const trimmed = override?.value.trim(); + if (!trimmed) { + return ""; + } + if (override.kind === "qualified") { + return trimmed; + } + + let matchedValue = ""; + for (const entry of catalog) { + if (entry.id.trim().toLowerCase() !== trimmed.toLowerCase()) { + continue; + } + const candidate = buildQualifiedChatModelValue(entry.id, entry.provider); + if (!matchedValue) { + matchedValue = candidate; + continue; + } + if (matchedValue.toLowerCase() !== candidate.toLowerCase()) { + return trimmed; + } + } + return matchedValue || trimmed; +} + +export function resolveServerChatModelValue( + model?: string | null, + provider?: string | null, +): string { + if (typeof model !== "string") { + return ""; + } + return buildQualifiedChatModelValue(model, provider); +} + +export function formatChatModelDisplay(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const separator = trimmed.indexOf("/"); + if (separator <= 0) { + return trimmed; + } + return `${trimmed.slice(separator + 1)} · ${trimmed.slice(0, separator)}`; +} + +export function buildChatModelOption(entry: ModelCatalogEntry): { value: string; label: string } { + const provider = entry.provider?.trim(); + return { + value: buildQualifiedChatModelValue(entry.id, provider), + label: provider ? `${entry.id} · ${provider}` : entry.id, + }; +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index d08c62b97d9..96170fa8940 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -235,7 +235,7 @@ describe("executeSlashCommand directives", () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { return { - defaults: { model: "default-model" }, + defaults: { modelProvider: "openai", model: "default-model" }, sessions: [ row("agent:main:main", { model: "gpt-4.1-mini", @@ -265,6 +265,38 @@ describe("executeSlashCommand directives", () => { expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); }); + it("mirrors resolved provider-qualified model refs after /model changes", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.patch") { + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "model", + "gpt-5-mini", + ); + + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "main", + model: "gpt-5-mini", + }); + expect(result.sessionPatch?.modelOverride).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); + }); + it("resolves the legacy main alias for /usage", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 38b1690fe29..1db10dd93d6 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -16,8 +16,15 @@ import { isSubagentSessionKey, parseAgentSessionKey, } from "../../../../src/routing/session-key.js"; +import { createChatModelOverride, resolveServerChatModelValue } from "../chat-model-ref.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { + AgentsListResult, + ChatModelOverride, + GatewaySessionRow, + SessionsListResult, + SessionsPatchResult, +} from "../types.ts"; import { SLASH_COMMANDS } from "./slash-commands.ts"; export type SlashCommandResult = { @@ -35,7 +42,7 @@ export type SlashCommandResult = { | "navigate-usage"; /** Optional session-level directive changes that the caller should mirror locally. */ sessionPatch?: { - model?: string | null; + modelOverride?: ChatModelOverride | null; }; }; @@ -144,11 +151,18 @@ async function executeModel( } try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + const patched = await client.request("sessions.patch", { + key: sessionKey, + model: args.trim(), + }); + const resolvedValue = resolveServerChatModelValue( + patched.resolved?.model ?? args.trim(), + patched.resolved?.modelProvider, + ); return { content: `Model set to \`${args.trim()}\`.`, action: "refresh", - sessionPatch: { model: args.trim() }, + sessionPatch: { modelOverride: createChatModelOverride(resolvedValue) }, }; } catch (err) { return { content: `Failed to set model: ${String(err)}` }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 82c97c6744a..0d5aa3d61cd 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -321,6 +321,8 @@ export type GatewaySessionsDefaults = { contextTokens: number | null; }; +export type ChatModelOverride = import("./chat-model-ref.ts").ChatModelOverride; + export type GatewayAgentRow = SharedGatewayAgentRow; export type AgentsListResult = { @@ -402,7 +404,12 @@ export type SessionsPatchResult = SessionsPatchResultBase<{ verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; -}>; +}> & { + resolved?: { + modelProvider?: string; + model?: string; + }; +}; export type { CostUsageDailyEntry, diff --git a/ui/src/ui/views/chat.browser.test.ts b/ui/src/ui/views/chat.browser.test.ts index fa7947a328a..c17525bb60b 100644 --- a/ui/src/ui/views/chat.browser.test.ts +++ b/ui/src/ui/views/chat.browser.test.ts @@ -31,7 +31,7 @@ function createProps(overrides: Partial = {}): ChatProps { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "main", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index ab55db6935f..eea76e6482b 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -15,7 +15,7 @@ function createSessions(): SessionsListResult { ts: 0, path: "", count: 0, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [], }; } @@ -28,6 +28,7 @@ function createChatHeaderState( } = {}, ): { state: AppViewState; request: ReturnType } { let currentModel = overrides.model ?? null; + let currentModelProvider = currentModel ? "openai" : undefined; const omitSessionFromList = overrides.omitSessionFromList ?? false; const catalog = overrides.models ?? [ { id: "gpt-5", name: "GPT-5", provider: "openai" }, @@ -35,7 +36,26 @@ function createChatHeaderState( ]; const request = vi.fn(async (method: string, params: Record) => { if (method === "sessions.patch") { - currentModel = (params.model as string | null | undefined) ?? null; + const nextModel = (params.model as string | null | undefined) ?? null; + if (!nextModel) { + currentModel = null; + currentModelProvider = undefined; + } else { + const normalized = nextModel.trim(); + const slashIndex = normalized.indexOf("/"); + if (slashIndex > 0) { + currentModelProvider = normalized.slice(0, slashIndex); + currentModel = normalized.slice(slashIndex + 1); + } else { + currentModel = normalized; + const matchingProviders = catalog + .filter((entry) => entry.id === normalized) + .map((entry) => entry.provider) + .filter(Boolean); + currentModelProvider = + matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider; + } + } return { ok: true, key: "main" }; } if (method === "chat.history") { @@ -46,10 +66,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }; } if (method === "models.list") { @@ -65,10 +93,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }, chatModelOverrides: {}, chatModelCatalog: catalog, @@ -566,13 +602,13 @@ describe("chat view", () => { expect(modelSelect).not.toBeNull(); expect(modelSelect?.value).toBe(""); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", - model: "gpt-5-mini", + model: "openai/gpt-5-mini", }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); @@ -594,7 +630,7 @@ describe("chat view", () => { 'select[data-chat-model-select="true"]', ); expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe("gpt-5-mini"); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); modelSelect!.value = ""; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); @@ -638,7 +674,7 @@ describe("chat view", () => { ); expect(modelSelect).not.toBeNull(); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); render(renderChatSessionSelect(state), container); @@ -646,10 +682,30 @@ describe("chat view", () => { const rerendered = container.querySelector( 'select[data-chat-model-select="true"]', ); - expect(rerendered?.value).toBe("gpt-5-mini"); + expect(rerendered?.value).toBe("openai/gpt-5-mini"); vi.unstubAllGlobals(); }); + it("normalizes cached bare /model overrides to the matching catalog option", () => { + const { state } = createChatHeaderState(); + state.chatModelOverrides = { main: { kind: "raw", value: "gpt-5-mini" } }; + + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const modelSelect = container.querySelector( + 'select[data-chat-model-select="true"]', + ); + expect(modelSelect).not.toBeNull(); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); + + const optionValues = Array.from(modelSelect?.querySelectorAll("option") ?? []).map( + (option) => option.value, + ); + expect(optionValues).toContain("openai/gpt-5-mini"); + expect(optionValues).not.toContain("gpt-5-mini"); + }); + it("prefers the session label over displayName in the grouped chat session selector", () => { const { state } = createChatHeaderState({ omitSessionFromList: true }); state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; @@ -658,7 +714,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -708,7 +764,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -737,7 +793,7 @@ describe("chat view", () => { ts: 0, path: "", count: 2, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b", diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index fe650fef8fb..342af136a75 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -8,7 +8,7 @@ function buildResult(session: SessionsListResult["sessions"][number]): SessionsL ts: Date.now(), path: "(multiple)", count: 1, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [session], }; } From cb4a298961ca1292e49afc8d010013f64cb06bcd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:11:39 -0700 Subject: [PATCH 107/331] CLI: route gateway status through daemon status --- src/cli/program/routes.test.ts | 62 ++++++++++++++++++++++++---------- src/cli/program/routes.ts | 30 ++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 65cba06e299..87849fb4d0b 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,7 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); -const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runDaemonStatusMock = vi.hoisted(() => vi.fn(async () => {})); const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ @@ -18,8 +18,8 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); -vi.mock("../../commands/gateway-status.js", () => ({ - gatewayStatusCommand: gatewayStatusCommandMock, +vi.mock("../daemon-cli/status.js", () => ({ + runDaemonStatus: runDaemonStatusMock, })); vi.mock("../../commands/status-json.js", () => ({ @@ -77,14 +77,24 @@ describe("program routes", () => { ["gateway", "status"], ["node", "openclaw", "gateway", "status", "--timeout"], ); - await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + }); + + it("returns false for gateway status route when probe-only flags are present", async () => { await expectRunFalse( ["gateway", "status"], - ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ["node", "openclaw", "gateway", "status", "--ssh", "user@host"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity", "~/.ssh/id_test"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-auto"], ); }); - it("passes parsed gateway status flags through", async () => { + it("passes parsed gateway status flags through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); await expect( route?.run([ @@ -102,27 +112,43 @@ describe("program routes", () => { "def", "--timeout", "5000", - "--ssh", - "user@host", - "--ssh-identity", - "~/.ssh/id_test", - "--ssh-auto", + "--deep", + "--require-rpc", "--json", ]), ).resolves.toBe(true); - expect(gatewayStatusCommandMock).toHaveBeenCalledWith( - { + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { url: "ws://127.0.0.1:18789", token: "abc", password: "def", timeout: "5000", - json: true, - ssh: "user@host", - sshIdentity: "~/.ssh/id_test", - sshAuto: true, }, - expect.any(Object), + probe: true, + requireRpc: true, + deep: true, + json: true, + }); + }); + + it("passes --no-probe through to daemon status", async () => { + const route = expectRoute(["gateway", "status"]); + await expect(route?.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( + true, ); + + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { + url: undefined, + token: undefined, + password: undefined, + timeout: undefined, + }, + probe: false, + requireRpc: false, + deep: false, + json: false, + }); }); it("returns false when status timeout flag value is missing", async () => { diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 913f84dd2e4..cbb6d6dbfdc 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -81,26 +81,36 @@ const routeGatewayStatus: RouteSpec = { if (ssh === null) { return false; } + if (ssh !== undefined) { + return false; + } const sshIdentity = getFlagValue(argv, "--ssh-identity"); if (sshIdentity === null) { return false; } - const sshAuto = hasFlag(argv, "--ssh-auto"); + if (sshIdentity !== undefined) { + return false; + } + if (hasFlag(argv, "--ssh-auto")) { + return false; + } + const deep = hasFlag(argv, "--deep"); const json = hasFlag(argv, "--json"); - const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); - await gatewayStatusCommand( - { + const requireRpc = hasFlag(argv, "--require-rpc"); + const probe = !hasFlag(argv, "--no-probe"); + const { runDaemonStatus } = await import("../daemon-cli/status.js"); + await runDaemonStatus({ + rpc: { url: url ?? undefined, token: token ?? undefined, password: password ?? undefined, timeout: timeout ?? undefined, - json, - ssh: ssh ?? undefined, - sshIdentity: sshIdentity ?? undefined, - sshAuto, }, - defaultRuntime, - ); + probe, + requireRpc, + deep, + json, + }); return true; }, }; From 7781f62d33518a67e25309fa12c811d272e1cdb8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:28:56 -0700 Subject: [PATCH 108/331] Status: restore lazy scan runtime typing --- src/commands/status.scan.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 64a17e2b371..a74b9bbc131 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,6 +48,10 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; +type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -159,9 +163,9 @@ export type StatusScanResult = { gatewayProbe: Awaited> | null; gatewayReachable: boolean; gatewaySelf: ReturnType; - channelIssues: ReturnType; + channelIssues: ChannelStatusIssues; agentStatus: Awaited>; - channels: Awaited>; + channels: ChannelsTable; summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; From 33edb57e745b4e3fab788248fa8b52cbb5727062 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:30:10 -0700 Subject: [PATCH 109/331] fix: keep provider resolution from clobbering channel plugins --- src/commands/status.scan.ts | 8 ++++---- src/plugins/provider-runtime.ts | 2 ++ src/plugins/providers.ts | 4 ++++ ui/src/ui/views/chat.test.ts | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index a74b9bbc131..4ef90bf1da0 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,10 +48,6 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; -type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); -type ChannelStatusIssues = ReturnType; -type ChannelsTable = Awaited>; - function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -62,6 +58,10 @@ function loadStatusScanRuntimeModule() { return statusScanRuntimeModulePromise; } +type StatusScanRuntimeModule = Awaited>; +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 8997011a7c9..41c0a70ec4d 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -39,6 +39,8 @@ function resolveProviderPluginsForHooks(params: { }): ProviderPlugin[] { return resolvePluginProviders({ ...params, + activate: false, + cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e3215f2c6da..37f937d5a91 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -122,6 +122,8 @@ export function resolvePluginProviders(params: { bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; + activate?: boolean; + cache?: boolean; }): ProviderPlugin[] { const maybeAllowlistCompat = params.bundledProviderAllowlistCompat ? withBundledPluginAllowlistCompat({ @@ -140,6 +142,8 @@ export function resolvePluginProviders(params: { workspaceDir: params.workspaceDir, env: params.env, onlyPluginIds: params.onlyPluginIds, + activate: params.activate, + cache: params.cache, logger: createPluginLoaderLogger(log), }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index eea76e6482b..6907cafa0ed 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -612,6 +612,7 @@ describe("chat view", () => { }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); + expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai"); vi.unstubAllGlobals(); }); From b8bb8510a2a382632cae058200c9e90a591e1ffd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:35:20 -0700 Subject: [PATCH 110/331] feat: move ssh sandboxing into core --- CHANGELOG.md | 1 + docs/cli/sandbox.md | 19 +- docs/gateway/configuration-reference.md | 35 +- docs/gateway/sandboxing.md | 55 ++ docs/gateway/secrets.md | 3 + extensions/openshell/src/backend.test.ts | 1 + extensions/openshell/src/backend.ts | 41 +- extensions/openshell/src/cli.ts | 124 +--- extensions/openshell/src/remote-fs-bridge.ts | 554 +----------------- src/agents/sandbox-merge.test.ts | 36 ++ src/agents/sandbox.ts | 19 + src/agents/sandbox/backend.ts | 12 +- src/agents/sandbox/browser.create.test.ts | 6 + src/agents/sandbox/config.ts | 59 ++ .../docker.config-hash-recreate.test.ts | 6 + src/agents/sandbox/manage.ts | 9 +- src/agents/sandbox/prune.ts | 9 +- src/agents/sandbox/remote-fs-bridge.ts | 518 ++++++++++++++++ src/agents/sandbox/ssh-backend.ts | 303 ++++++++++ src/agents/sandbox/ssh.test.ts | 61 ++ src/agents/sandbox/ssh.ts | 334 +++++++++++ src/agents/sandbox/types.ts | 15 + src/config/types.agents-shared.ts | 3 + src/config/types.sandbox.ts | 27 + src/config/zod-schema.agent-runtime.ts | 18 + src/plugin-sdk/core.ts | 15 + src/secrets/runtime-config-collectors-core.ts | 85 +++ src/secrets/runtime.test.ts | 40 ++ 28 files changed, 1724 insertions(+), 684 deletions(-) create mode 100644 src/agents/sandbox/remote-fs-bridge.ts create mode 100644 src/agents/sandbox/ssh-backend.ts create mode 100644 src/agents/sandbox/ssh.test.ts create mode 100644 src/agents/sandbox/ssh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07937512400..ea4239d1e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. +- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. ### Fixes diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5ebac698175..f320be3b771 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -16,6 +16,7 @@ OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` Today that usually means: - Docker sandbox containers +- SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` ## Commands @@ -97,6 +98,22 @@ openclaw sandbox recreate --all openclaw sandbox recreate --all ``` +### After changing SSH target or SSH auth material + +```bash +# Edit config: +# - agents.defaults.sandbox.backend +# - agents.defaults.sandbox.ssh.target +# - agents.defaults.sandbox.ssh.workspaceRoot +# - agents.defaults.sandbox.ssh.identityFile / certificateFile / knownHostsFile +# - agents.defaults.sandbox.ssh.identityData / certificateData / knownHostsData + +openclaw sandbox recreate --all +``` + +For the core `ssh` backend, recreate deletes the per-scope remote workspace root +on the SSH target. The next run seeds it again from the local workspace. + ### After changing OpenShell source, policy, or mode ```bash @@ -150,7 +167,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, openshell + "backend": "docker", // docker, ssh, openshell "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 951f99f1165..ecefd8bbc4e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1125,7 +1125,7 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing defaults: { sandbox: { mode: "non-main", // off | non-main | all - backend: "docker", // docker | openshell + backend: "docker", // docker | ssh | openshell scope: "agent", // session | agent | shared workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -1154,6 +1154,20 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing extraHosts: ["internal.service:10.0.0.5"], binds: ["/home/user/source:/source:rw"], }, + ssh: { + target: "user@gateway-host:22", + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // SecretRefs / inline contents also supported: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, browser: { enabled: false, image: "openclaw-sandbox-browser:bookworm-slim", @@ -1203,11 +1217,29 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing **Backend:** - `docker`: local Docker runtime (default) +- `ssh`: generic SSH-backed remote runtime - `openshell`: OpenShell runtime When `backend: "openshell"` is selected, runtime-specific settings move to `plugins.entries.openshell.config`. +**SSH backend config:** + +- `target`: SSH target in `user@host[:port]` form +- `command`: SSH client command (default: `ssh`) +- `workspaceRoot`: absolute remote root used for per-scope workspaces +- `identityFile` / `certificateFile` / `knownHostsFile`: existing local files passed to OpenSSH +- `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime +- `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs + +**SSH backend behavior:** + +- seeds the remote workspace once after create or recreate +- then keeps the remote SSH workspace canonical +- routes `exec`, file tools, and media paths over SSH +- does not sync remote changes back to the host automatically +- does not support sandbox browser containers + **Workspace access:** - `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes` @@ -1252,6 +1284,7 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step. +Transport is SSH into the OpenShell sandbox, but the plugin owns sandbox lifecycle and optional mirror sync. **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index db40b802832..b37757334c0 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -59,10 +59,61 @@ Not sandboxed: `agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: - `"docker"` (default): local Docker-backed sandbox runtime. +- `"ssh"`: generic SSH-backed remote sandbox runtime. - `"openshell"`: OpenShell-backed sandbox runtime. +SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specific config lives under `plugins.entries.openshell.config`. +### SSH backend + +Use `backend: "ssh"` when you want OpenClaw to sandbox `exec`, file tools, and media reads on +an arbitrary SSH-accessible machine. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "user@gateway-host:22", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // Or use SecretRefs / inline contents instead of local files: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +How it works: + +- OpenClaw creates a per-scope remote root under `sandbox.ssh.workspaceRoot`. +- On first use after create or recreate, OpenClaw seeds that remote workspace from the local workspace once. +- After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. +- OpenClaw does not sync remote changes back to the local workspace automatically. + +This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. + +Important consequences: + +- Host-local edits made outside OpenClaw after the seed step are not visible remotely until you recreate the sandbox. +- `openclaw sandbox recreate` deletes the per-scope remote root and seeds again from local on next use. +- Browser sandboxing is not supported on the SSH backend. +- `sandbox.docker.*` settings do not apply to the SSH backend. + ```json5 { agents: { @@ -96,6 +147,9 @@ OpenShell modes: - `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec. - `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back. +OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. +The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. + Current OpenShell limitations: - sandbox browser is not supported yet @@ -136,6 +190,7 @@ Behavior: - After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace. - OpenClaw does **not** sync remote changes back into the local workspace after exec. - Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path. +- Transport is SSH into the OpenShell sandbox returned by `openshell sandbox ssh-config`. Important consequences: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 379e4a527d4..eb044eaf03c 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -41,6 +41,9 @@ Examples of inactive surfaces: - Web search provider-specific keys that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected. +- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`, + `certificateData`, `knownHostsData`, plus per-agent overrides) is active only + when the effective sandbox backend is `ssh` for the default agent or an enabled agent. - `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true: - `gateway.mode=remote` - `gateway.remote.url` is configured diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts index 2999599c648..2685d7effa8 100644 --- a/extensions/openshell/src/backend.test.ts +++ b/extensions/openshell/src/backend.test.ts @@ -101,6 +101,7 @@ describe("openshell backend manager", () => { image: "openclaw", configLabelKind: "Source", }, + config: {}, }); expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 85c3d415904..d87b1c92af8 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -4,43 +4,44 @@ import path from "node:path"; import type { CreateSandboxBackendParams, OpenClawConfig, + RemoteShellSandboxHandle, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendFactory, SandboxBackendHandle, SandboxBackendManager, + SshSandboxSession, +} from "openclaw/plugin-sdk/core"; +import { + createRemoteShellSandboxFsBridge, + disposeSshSandboxSession, + resolvePreferredOpenClawTmpDir, + runSshSandboxCommand, } from "openclaw/plugin-sdk/core"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; import { buildExecRemoteCommand, buildRemoteCommand, createOpenShellSshSession, - disposeOpenShellSshSession, runOpenShellCli, - runOpenShellSshCommand, type OpenShellExecContext, - type OpenShellSshSession, } from "./cli.js"; import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; import { createOpenShellFsBridge } from "./fs-bridge.js"; import { replaceDirectoryContents } from "./mirror.js"; -import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; type CreateOpenShellSandboxBackendFactoryParams = { pluginConfig: ResolvedOpenShellPluginConfig; }; type PendingExec = { - sshSession: OpenShellSshSession; + sshSession: SshSandboxSession; }; -export type OpenShellSandboxBackend = SandboxBackendHandle & { - mode: "mirror" | "remote"; - remoteWorkspaceDir: string; - remoteAgentWorkspaceDir: string; - runRemoteShellScript(params: SandboxBackendCommandParams): Promise; - syncLocalPathToRemote(localPath: string, remotePath: string): Promise; -}; +export type OpenShellSandboxBackend = SandboxBackendHandle & + RemoteShellSandboxHandle & { + mode: "mirror" | "remote"; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; + }; export function createOpenShellSandboxBackendFactory( params: CreateOpenShellSandboxBackendFactoryParams, @@ -129,9 +130,9 @@ async function createOpenShellSandboxBackend(params: { runShellCommand: async (command) => await impl.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => params.pluginConfig.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: impl.asHandle(), + runtime: impl.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -186,9 +187,9 @@ class OpenShellSandboxBackendImpl { runShellCommand: async (command) => await self.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: self.asHandle(), + runtime: self.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -242,7 +243,7 @@ class OpenShellSandboxBackendImpl { } } finally { if (token?.sshSession) { - await disposeOpenShellSshSession(token.sshSession); + await disposeSshSandboxSession(token.sshSession); } } } @@ -262,7 +263,7 @@ class OpenShellSandboxBackendImpl { context: this.params.execContext, }); try { - return await runOpenShellSshCommand({ + return await runSshSandboxCommand({ session, remoteCommand: buildRemoteCommand([ "/bin/sh", @@ -276,7 +277,7 @@ class OpenShellSandboxBackendImpl { signal: params.signal, }); } finally { - await disposeOpenShellSshSession(session); + await disposeSshSandboxSession(session); } } diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 8f9808b5164..411166520e7 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -1,34 +1,20 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { - resolvePreferredOpenClawTmpDir, + buildExecRemoteCommand, + createSshSandboxSessionFromConfigText, runPluginCommandWithTimeout, + shellEscape, + type SshSandboxSession, } from "openclaw/plugin-sdk/core"; -import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; import type { ResolvedOpenShellPluginConfig } from "./config.js"; +export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core"; + export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; sandboxName: string; timeoutMs?: number; }; -export type OpenShellSshSession = { - configPath: string; - host: string; -}; - -export type OpenShellRunSshCommandParams = { - session: OpenShellSshSession; - remoteCommand: string; - stdin?: Buffer | string; - allowFailure?: boolean; - signal?: AbortSignal; - tty?: boolean; -}; - export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { const argv = [config.command]; if (config.gateway) { @@ -40,10 +26,6 @@ export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): s return argv; } -export function shellEscape(value: string): string { - return `'${value.replaceAll("'", `'\"'\"'`)}'`; -} - export function buildRemoteCommand(argv: string[]): string { return argv.map((entry) => shellEscape(entry)).join(" "); } @@ -64,7 +46,7 @@ export async function runOpenShellCli(params: { export async function createOpenShellSshSession(params: { context: OpenShellExecContext; -}): Promise { +}): Promise { const result = await runOpenShellCli({ context: params.context, args: ["sandbox", "ssh-config", params.context.sandboxName], @@ -72,95 +54,7 @@ export async function createOpenShellSshSession(params: { if (result.code !== 0) { throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); } - const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); - const host = hostMatch?.[1]?.trim(); - if (!host) { - throw new Error("Failed to parse openshell ssh-config output."); - } - const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); - await fs.mkdir(tmpRoot, { recursive: true }); - const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); - const configPath = path.join(configDir, "config"); - await fs.writeFile(configPath, result.stdout, "utf8"); - return { configPath, host }; -} - -export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { - await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); -} - -export async function runOpenShellSshCommand( - params: OpenShellRunSshCommandParams, -): Promise { - const argv = [ - "ssh", - "-F", - params.session.configPath, - ...(params.tty - ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] - : ["-T", "-o", "RequestTTY=no"]), - params.session.host, - params.remoteCommand, - ]; - - const result = await new Promise((resolve, reject) => { - const child = spawn(argv[0]!, argv.slice(1), { - stdio: ["pipe", "pipe", "pipe"], - env: process.env, - signal: params.signal, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (code) => { - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - const exitCode = code ?? 0; - if (exitCode !== 0 && !params.allowFailure) { - const error = Object.assign( - new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), - { - code: exitCode, - stdout, - stderr, - }, - ); - reject(error); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - - if (params.stdin !== undefined) { - child.stdin.end(params.stdin); - return; - } - child.stdin.end(); + return await createSshSandboxSessionFromConfigText({ + configText: result.stdout, }); - - return result; -} - -export function buildExecRemoteCommand(params: { - command: string; - workdir?: string; - env: Record; -}): string { - const body = params.workdir - ? `cd ${shellEscape(params.workdir)} && ${params.command}` - : params.command; - const argv = - Object.keys(params.env).length > 0 - ? [ - "env", - ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), - "/bin/sh", - "-c", - body, - ] - : ["/bin/sh", "-c", body]; - return buildRemoteCommand(argv); } diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 3560fa78f28..9cc1ddf704d 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -1,550 +1,16 @@ -import path from "node:path"; -import type { - SandboxContext, - SandboxFsBridge, - SandboxFsStat, - SandboxResolvedPath, +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, + type SandboxContext, + type SandboxFsBridge, } from "openclaw/plugin-sdk/core"; -import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; -import type { OpenShellSandboxBackend } from "./backend.js"; - -type ResolvedRemotePath = SandboxResolvedPath & { - writable: boolean; - mountRootPath: string; - source: "workspace" | "agent"; -}; - -type MountInfo = { - containerRoot: string; - writable: boolean; - source: "workspace" | "agent"; -}; export function createOpenShellRemoteFsBridge(params: { sandbox: SandboxContext; - backend: OpenShellSandboxBackend; + backend: RemoteShellSandboxHandle; }): SandboxFsBridge { - return new OpenShellRemoteFsBridge(params.sandbox, params.backend); -} - -class OpenShellRemoteFsBridge implements SandboxFsBridge { - constructor( - private readonly sandbox: SandboxContext, - private readonly backend: OpenShellSandboxBackend, - ) {} - - resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { - const target = this.resolveTarget(params); - return { - relativePath: target.relativePath, - containerPath: target.containerPath, - }; - } - - async readFile(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "read files", - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "read files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\ncat -- "$1"', - args: [canonical], - signal: params.signal, - }); - return result.stdout; - } - - async writeFile(params: { - filePath: string; - cwd?: string; - data: Buffer | string; - encoding?: BufferEncoding; - mkdir?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "write files"); - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "write files", - requireWritable: true, - }); - await this.assertNoHardlinkedFile({ - containerPath: target.containerPath, - action: "write files", - signal: params.signal, - }); - const buffer = Buffer.isBuffer(params.data) - ? params.data - : Buffer.from(params.data, params.encoding ?? "utf8"); - await this.runMutation({ - args: [ - "write", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.mkdir !== false ? "1" : "0", - ], - stdin: buffer, - signal: params.signal, - }); - } - - async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "create directories"); - const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); - if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, - ); - } - await this.runMutation({ - args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], - signal: params.signal, - }); - } - - async remove(params: { - filePath: string; - cwd?: string; - recursive?: boolean; - force?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "remove files"); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - if (params.force === false) { - throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); - } - return; - } - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "remove files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - await this.runMutation({ - args: [ - "remove", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.recursive ? "1" : "0", - params.force === false ? "0" : "1", - ], - signal: params.signal, - allowFailure: params.force !== false, - }); - } - - async rename(params: { - from: string; - to: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); - const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); - this.ensureWritable(from, "rename files"); - this.ensureWritable(to, "rename files"); - const fromPinned = await this.resolvePinnedParent({ - containerPath: from.containerPath, - action: "rename files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - const toPinned = await this.resolvePinnedParent({ - containerPath: to.containerPath, - action: "rename files", - requireWritable: true, - }); - await this.runMutation({ - args: [ - "rename", - fromPinned.mountRootPath, - fromPinned.relativeParentPath, - fromPinned.basename, - toPinned.mountRootPath, - toPinned.relativeParentPath, - toPinned.basename, - "1", - ], - signal: params.signal, - }); - } - - async stat(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - return null; - } - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "stat files", - signal: params.signal, - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "stat files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', - args: [canonical], - signal: params.signal, - }); - const output = result.stdout.toString("utf8").trim(); - const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); - return { - type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", - size: Number(sizeRaw), - mtimeMs: Number(mtimeRaw) * 1000, - }; - } - - private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { - const workspaceRoot = path.resolve(this.sandbox.workspaceDir); - const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); - const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); - const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); - const mounts: MountInfo[] = [ - { - containerRoot: workspaceContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ]; - if ( - this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ) { - mounts.push({ - containerRoot: agentContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "agent", - }); - } - - const input = params.filePath.trim(); - const inputPosix = input.replace(/\\/g, "/"); - const maybeContainerMount = path.posix.isAbsolute(inputPosix) - ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) - : null; - if (maybeContainerMount) { - return this.toResolvedPath({ - mount: maybeContainerMount, - containerPath: normalizeContainerPath(inputPosix), - }); - } - - const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; - const hostCandidate = path.isAbsolute(input) - ? path.resolve(input) - : path.resolve(hostCwd, input); - if (isPathInside(workspaceRoot, hostCandidate)) { - const relative = toPosixRelative(workspaceRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[0]!, - containerPath: relative - ? path.posix.join(workspaceContainerRoot, relative) - : workspaceContainerRoot, - }); - } - if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { - const relative = toPosixRelative(agentRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[1], - containerPath: relative - ? path.posix.join(agentContainerRoot, relative) - : agentContainerRoot, - }); - } - - if (params.cwd) { - const cwdPosix = params.cwd.replace(/\\/g, "/"); - if (path.posix.isAbsolute(cwdPosix)) { - const cwdContainer = normalizeContainerPath(cwdPosix); - const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); - if (cwdMount) { - return this.toResolvedPath({ - mount: cwdMount, - containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), - }); - } - } - } - - throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); - } - - private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { - const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); - if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, - ); - } - return { - relativePath: - params.mount.source === "workspace" - ? relative === "." - ? "" - : relative - : relative === "." - ? params.mount.containerRoot - : `${params.mount.containerRoot}/${relative}`, - containerPath: params.containerPath, - writable: params.mount.writable, - mountRootPath: params.mount.containerRoot, - source: params.mount.source, - }; - } - - private resolveMountByContainerPath( - mounts: MountInfo[], - containerPath: string, - ): MountInfo | null { - const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); - for (const mount of ordered) { - if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { - return mount; - } - } - return null; - } - - private ensureWritable(target: ResolvedRemotePath, action: string) { - if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { - throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); - } - } - - private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { - const result = await this.runRemoteScript({ - script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', - args: [containerPath], - signal, - }); - return result.stdout.toString("utf8").trim() === "1"; - } - - private async resolveCanonicalPath(params: { - containerPath: string; - action: string; - allowFinalSymlinkForUnlink?: boolean; - signal?: AbortSignal; - }): Promise { - const script = [ - "set -eu", - 'target="$1"', - 'allow_final="$2"', - 'suffix=""', - 'probe="$target"', - 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', - 'cursor="$probe"', - 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', - ' parent=$(dirname -- "$cursor")', - ' if [ "$parent" = "$cursor" ]; then break; fi', - ' base=$(basename -- "$cursor")', - ' suffix="/$base$suffix"', - ' cursor="$parent"', - "done", - 'canonical=$(readlink -f -- "$cursor")', - 'printf "%s%s\\n" "$canonical" "$suffix"', - ].join("\n"); - const result = await this.runRemoteScript({ - script, - args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], - signal: params.signal, - }); - const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonical, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return canonical; - } - - private async assertNoHardlinkedFile(params: { - containerPath: string; - action: string; - signal?: AbortSignal; - }): Promise { - const result = await this.runRemoteScript({ - script: [ - 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', - 'stats=$(stat -c "%F|%h" -- "$1")', - 'printf "%s\\n" "$stats"', - ].join("\n"), - args: [params.containerPath], - signal: params.signal, - allowFailure: true, - }); - const output = result.stdout.toString("utf8").trim(); - if (!output) { - return; - } - const [kind = "", linksRaw = "1"] = output.split("|"); - if (kind === "regular file" && Number(linksRaw) > 1) { - throw new Error( - `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, - ); - } - } - - private async resolvePinnedParent(params: { - containerPath: string; - action: string; - requireWritable?: boolean; - allowFinalSymlinkForUnlink?: boolean; - }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { - const basename = path.posix.basename(params.containerPath); - if (!basename || basename === "." || basename === "/") { - throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); - } - const canonicalParent = await this.resolveCanonicalPath({ - containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), - action: params.action, - allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, - }); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonicalParent, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - if (params.requireWritable && !mount.writable) { - throw new Error( - `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, - ); - } - const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); - if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return { - mountRootPath: mount.containerRoot, - relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, - basename, - }; - } - - private async runMutation(params: { - args: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - await this.runRemoteScript({ - script: [ - "set -eu", - "python3 /dev/fd/3 \"$@\" 3<<'PY'", - SANDBOX_PINNED_MUTATION_PYTHON, - "PY", - ].join("\n"), - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } - - private async runRemoteScript(params: { - script: string; - args?: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - return await this.backend.runRemoteShellScript({ - script: params.script, - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } -} - -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value.trim() || "/"); - return normalized.startsWith("/") ? normalized : `/${normalized}`; -} - -function isPathInsideContainerRoot(root: string, candidate: string): boolean { - const normalizedRoot = normalizeContainerPath(root); - const normalizedCandidate = normalizeContainerPath(candidate); - return ( - normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) - ); -} - -function isPathInside(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function toPosixRelative(root: string, candidate: string): string { - return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); + return createRemoteShellSandboxFsBridge({ + sandbox: params.sandbox, + runtime: params.backend, + }); } diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index d120ac84820..742701017d2 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -5,6 +5,7 @@ import { resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, + resolveSandboxSshConfig, } from "./sandbox/config.js"; describe("sandbox config merges", () => { @@ -130,6 +131,41 @@ describe("sandbox config merges", () => { expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + it("merges sandbox ssh settings and ignores agent overrides under shared scope", () => { + const ssh = resolveSandboxSshConfig({ + scope: "agent", + globalSsh: { + target: "global@example.com:22", + command: "ssh", + identityFile: "~/.ssh/global", + strictHostKeyChecking: true, + }, + agentSsh: { + target: "agent@example.com:2222", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }, + }); + expect(ssh).toMatchObject({ + target: "agent@example.com:2222", + command: "ssh", + identityFile: "~/.ssh/global", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }); + + const sshShared = resolveSandboxSshConfig({ + scope: "shared", + globalSsh: { + target: "global@example.com:22", + }, + agentSsh: { + target: "agent@example.com:2222", + }, + }); + expect(sshShared.target).toBe("global@example.com:22"); + }); + it("defaults sandbox backend to docker", () => { expect(resolveSandboxConfigForAgent().backend).toBe("docker"); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index b52cb5ab050..d26dc75204d 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -34,6 +34,18 @@ export { export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; +export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, +} from "./sandbox/ssh.js"; +export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export type { CreateSandboxBackendParams, @@ -47,6 +59,12 @@ export type { SandboxBackendRegistration, SandboxBackendRuntimeInfo, } from "./sandbox/backend.js"; +export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js"; +export type { + RunSshSandboxCommandParams, + SshSandboxSession, + SshSandboxSettings, +} from "./sandbox/ssh.js"; export type { SandboxBrowserConfig, @@ -56,6 +74,7 @@ export type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, SandboxToolPolicy, SandboxToolPolicyResolved, SandboxToolPolicySource, diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index c186b0fe4cc..013cb565176 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -65,7 +65,11 @@ export type SandboxBackendManager = { config: OpenClawConfig; agentId?: string; }): Promise; - removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; + removeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; }; export type CreateSandboxBackendParams = { @@ -141,8 +145,14 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory } import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; registerSandboxBackend("docker", { factory: createDockerSandboxBackend, manager: dockerSandboxBackendManager, }); + +registerSandboxBackend("ssh", { + factory: createSshSandboxBackend, + manager: sshSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index c62276c6b87..88b5feccccc 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -62,6 +62,12 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig { capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: true, image: "openclaw-sandbox-browser:bookworm-slim", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index dda3e048ea7..c5bd29e9d11 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -1,4 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxSshSettings } from "../../config/types.sandbox.js"; +import { normalizeSecretInputString } from "../../config/types.secrets.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, @@ -22,6 +24,7 @@ import type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, } from "./types.js"; export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ @@ -30,6 +33,9 @@ export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ "dangerouslyAllowContainerNamespaceJoin", ] as const; +const DEFAULT_SANDBOX_SSH_COMMAND = "ssh"; +const DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT = "/tmp/openclaw-sandboxes"; + type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number]; type DangerousSandboxDockerBooleans = Pick; @@ -167,6 +173,54 @@ export function resolveSandboxPruneConfig(params: { }; } +function normalizeOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRemoteRoot(value: string | undefined, fallback: string): string { + const normalized = normalizeOptionalString(value) ?? fallback; + const posix = normalized.replaceAll("\\", "/"); + if (!posix.startsWith("/")) { + throw new Error(`Sandbox SSH workspaceRoot must be an absolute POSIX path: ${normalized}`); + } + return posix.replace(/\/+$/g, "") || "/"; +} + +export function resolveSandboxSshConfig(params: { + scope: SandboxScope; + globalSsh?: Partial; + agentSsh?: Partial; +}): SandboxSshConfig { + const agentSsh = params.scope === "shared" ? undefined : params.agentSsh; + const globalSsh = params.globalSsh; + return { + target: normalizeOptionalString(agentSsh?.target ?? globalSsh?.target), + command: + normalizeOptionalString(agentSsh?.command ?? globalSsh?.command) ?? + DEFAULT_SANDBOX_SSH_COMMAND, + workspaceRoot: normalizeRemoteRoot( + agentSsh?.workspaceRoot ?? globalSsh?.workspaceRoot, + DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT, + ), + strictHostKeyChecking: + agentSsh?.strictHostKeyChecking ?? globalSsh?.strictHostKeyChecking ?? true, + updateHostKeys: agentSsh?.updateHostKeys ?? globalSsh?.updateHostKeys ?? true, + identityFile: normalizeOptionalString(agentSsh?.identityFile ?? globalSsh?.identityFile), + certificateFile: normalizeOptionalString( + agentSsh?.certificateFile ?? globalSsh?.certificateFile, + ), + knownHostsFile: normalizeOptionalString(agentSsh?.knownHostsFile ?? globalSsh?.knownHostsFile), + identityData: normalizeSecretInputString(agentSsh?.identityData ?? globalSsh?.identityData), + certificateData: normalizeSecretInputString( + agentSsh?.certificateData ?? globalSsh?.certificateData, + ), + knownHostsData: normalizeSecretInputString( + agentSsh?.knownHostsData ?? globalSsh?.knownHostsData, + ), + }; +} + export function resolveSandboxConfigForAgent( cfg?: OpenClawConfig, agentId?: string, @@ -199,6 +253,11 @@ export function resolveSandboxConfigForAgent( globalDocker: agent?.docker, agentDocker: agentSandbox?.docker, }), + ssh: resolveSandboxSshConfig({ + scope, + globalSsh: agent?.ssh, + agentSsh: agentSandbox?.ssh, + }), browser: resolveSandboxBrowserConfig({ scope, globalBrowser: agent?.browser, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 54941ba04d1..46d37f9fd61 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -109,6 +109,12 @@ function createSandboxConfig( binds: binds ?? ["/tmp/workspace:/workspace:rw"], dangerouslyAllowReservedContainerTargets: true, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: false, image: "openclaw-browser:test", diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 0b5ba578d7d..c6e6f3fd7bf 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -85,16 +85,22 @@ export async function listSandboxBrowsers(): Promise { } export async function removeSandboxContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + agentId: resolveSandboxAgentId(entry.sessionKey), + }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readBrowserRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { @@ -105,6 +111,7 @@ export async function removeSandboxBrowserContainer(containerName: string): Prom runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); } await removeBrowserRegistryEntry(containerName); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 6ccfd8ac238..8005c23330e 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,4 +1,5 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; @@ -62,18 +63,23 @@ async function pruneSandboxRegistryEntries( } async function pruneSandboxContainers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries({ cfg, read: readRegistry, remove: removeRegistryEntry, removeRuntime: async (entry) => { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + }); }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries< SandboxBrowserRegistryEntry & { backendId?: string; @@ -92,6 +98,7 @@ async function pruneSandboxBrowsers(cfg: SandboxConfig) { runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); }, onRemoved: async (entry) => { diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts new file mode 100644 index 00000000000..ef70e928eac --- /dev/null +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -0,0 +1,518 @@ +import path from "node:path"; +import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; +import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js"; +import type { SandboxContext } from "./types.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export type RemoteShellSandboxHandle = { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; +}; + +export function createRemoteShellSandboxFsBridge(params: { + sandbox: SandboxContext; + runtime: RemoteShellSandboxHandle; +}): SandboxFsBridge { + return new RemoteShellSandboxFsBridge(params.sandbox, params.runtime); +} + +class RemoteShellSandboxFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly runtime: RemoteShellSandboxHandle, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private getMounts(): MountInfo[] { + const mounts: MountInfo[] = [ + { + containerRoot: normalizeContainerPath(this.runtime.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + return mounts; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.runtime.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir); + const mounts = this.getMounts(); + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0], + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + if (!this.resolveMountByContainerPath(this.getMounts(), canonical)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath(this.getMounts(), canonicalParent); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.runtime.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts new file mode 100644 index 00000000000..f241103fc19 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.ts @@ -0,0 +1,303 @@ +import path from "node:path"; +import type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendHandle, + SandboxBackendManager, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, +} from "./remote-fs-bridge.js"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + uploadDirectoryToSshTarget, + type SshSandboxSession, +} from "./ssh.js"; + +type PendingExec = { + sshSession: SshSandboxSession; +}; + +type ResolvedSshRuntimePaths = { + runtimeId: string; + runtimeRootDir: string; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; +}; + +export const sshSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return { + running: false, + actualConfigLabel: cfg.ssh.target, + configLabelMatch: false, + }; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + const result = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + runtimePaths.runtimeRootDir, + ]), + }); + return { + running: result.stdout.toString("utf8").trim() === "1", + actualConfigLabel: cfg.ssh.target, + configLabelMatch: entry.image === cfg.ssh.target, + }; + } finally { + await disposeSshSandboxSession(session); + } + }, + async removeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'rm -rf -- "$1"', + "openclaw-sandbox-remove", + runtimePaths.runtimeRootDir, + ]), + allowFailure: true, + }); + } finally { + await disposeSshSandboxSession(session); + } + }, +}; + +export async function createSshSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + if ((params.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("SSH sandbox backend does not support sandbox.docker.binds."); + } + const target = params.cfg.ssh.target; + if (!target) { + throw new Error('Sandbox backend "ssh" requires agents.defaults.sandbox.ssh.target.'); + } + + const runtimePaths = resolveSshRuntimePaths(params.cfg.ssh.workspaceRoot, params.scopeKey); + const impl = new SshSandboxBackendImpl({ + createParams: params, + target, + runtimePaths, + }); + return impl.asHandle(); +} + +class SshSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + target: string; + runtimePaths: ResolvedSshRuntimePaths; + }, + ) {} + + asHandle(): SandboxBackendHandle & RemoteShellSandboxHandle { + return { + id: "ssh", + runtimeId: this.params.runtimePaths.runtimeId, + runtimeLabel: this.params.runtimePaths.runtimeId, + workdir: this.params.runtimePaths.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.target, + configLabelKind: "Target", + remoteWorkspaceDir: this.params.runtimePaths.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.runtimePaths.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + await this.ensureRuntime(); + const sshSession = await this.createSession(); + const remoteCommand = buildExecRemoteCommand({ + command, + workdir: workdir ?? this.params.runtimePaths.remoteWorkspaceDir, + env, + }); + return { + argv: buildSshSandboxArgv({ + session: sshSession, + remoteCommand, + tty: usePty, + }), + env: process.env, + stdinMode: "pipe-open", + finalizeToken: { sshSession } satisfies PendingExec, + }; + }, + finalizeExec: async ({ token }) => { + const sshSession = (token as PendingExec | undefined)?.sshSession; + if (sshSession) { + await disposeSshSandboxSession(sshSession); + } + }, + runShellCommand: async (command) => await this.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createRemoteShellSandboxFsBridge({ + sandbox, + runtime: this.asHandle(), + }), + runRemoteShellScript: async (command) => await this.runRemoteShellScript(command), + }; + } + + private async createSession(): Promise { + return await createSshSandboxSessionFromSettings({ + ...this.params.createParams.cfg.ssh, + target: this.params.target, + }); + } + + private async ensureRuntime(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureRuntimeInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureRuntimeInner(): Promise { + const session = await this.createSession(); + try { + const exists = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + this.params.runtimePaths.runtimeRootDir, + ]), + }); + if (exists.stdout.toString("utf8").trim() === "1") { + return; + } + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.workspaceDir, + this.params.runtimePaths.remoteWorkspaceDir, + ); + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.agentWorkspaceDir, + this.params.runtimePaths.remoteAgentWorkspaceDir, + ); + } + } finally { + await disposeSshSandboxSession(session); + } + } + + private async replaceRemoteDirectoryFromLocal( + session: SshSandboxSession, + localDir: string, + remoteDir: string, + ): Promise { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + "openclaw-sandbox-clear", + remoteDir, + ]), + }); + await uploadDirectoryToSshTarget({ + session, + localDir, + remoteDir, + }); + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureRuntime(); + const session = await this.createSession(); + try { + return await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-sandbox-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeSshSandboxSession(session); + } + } +} + +function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths { + const runtimeId = buildSshSandboxRuntimeId(scopeKey); + const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId); + return { + runtimeId, + runtimeRootDir, + remoteWorkspaceDir: path.posix.join(runtimeRootDir, "workspace"), + remoteAgentWorkspaceDir: path.posix.join(runtimeRootDir, "agent"), + }; +} + +function buildSshSandboxRuntimeId(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-ssh-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts new file mode 100644 index 00000000000..c2c07a3bf11 --- /dev/null +++ b/src/agents/sandbox/ssh.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildExecRemoteCommand, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + type SshSandboxSession, +} from "./ssh.js"; + +const sessions: SshSandboxSession[] = []; + +afterEach(async () => { + await Promise.all( + sessions.splice(0).map(async (session) => { + await disposeSshSandboxSession(session); + }), + ); +}); + +describe("sandbox ssh helpers", () => { + it("materializes inline ssh auth data into a temp config", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const config = await fs.readFile(session.configPath, "utf8"); + expect(config).toContain("Host openclaw-sandbox"); + expect(config).toContain("HostName example.com"); + expect(config).toContain("User peter"); + expect(config).toContain("Port 2222"); + expect(config).toContain("StrictHostKeyChecking yes"); + expect(config).toContain("UpdateHostKeys no"); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY"); + expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT"); + expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe( + "example.com ssh-ed25519 AAAATEST", + ); + }); + + it("wraps remote exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts new file mode 100644 index 00000000000..1590b515e8f --- /dev/null +++ b/src/agents/sandbox/ssh.ts @@ -0,0 +1,334 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parseSshTarget } from "../../infra/ssh-tunnel.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { resolveUserPath } from "../../utils.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; + +export type SshSandboxSettings = { + command: string; + target: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + +export type SshSandboxSession = { + command: string; + configPath: string; + host: string; +}; + +export type RunSshSandboxCommandParams = { + session: SshSandboxSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} + +export function buildSshSandboxArgv(params: { + session: SshSandboxSession; + remoteCommand: string; + tty?: boolean; +}): string[] { + return [ + params.session.command, + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; +} + +export async function createSshSandboxSessionFromConfigText(params: { + configText: string; + host?: string; + command?: string; +}): Promise { + const host = params.host?.trim() || parseSshConfigHost(params.configText); + if (!host) { + throw new Error("Failed to parse SSH config output."); + } + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(configPath, 0o600); + return { + command: params.command?.trim() || "ssh", + configPath, + host, + }; +} + +export async function createSshSandboxSessionFromSettings( + settings: SshSandboxSettings, +): Promise { + const parsed = parseSshTarget(settings.target); + if (!parsed) { + throw new Error(`Invalid sandbox SSH target: ${settings.target}`); + } + + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + try { + const materializedIdentity = settings.identityData + ? await writeSecretMaterial(configDir, "identity", settings.identityData) + : undefined; + const materializedCertificate = settings.certificateData + ? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData) + : undefined; + const materializedKnownHosts = settings.knownHostsData + ? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData) + : undefined; + const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile); + const certificateFile = + materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile); + const knownHostsFile = + materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile); + const hostAlias = "openclaw-sandbox"; + const configPath = path.join(configDir, "config"); + const lines = [ + `Host ${hostAlias}`, + ` HostName ${parsed.host}`, + ` Port ${parsed.port}`, + " BatchMode yes", + " ConnectTimeout 5", + " ServerAliveInterval 15", + " ServerAliveCountMax 3", + ` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`, + ` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`, + ]; + if (parsed.user) { + lines.push(` User ${parsed.user}`); + } + if (knownHostsFile) { + lines.push(` UserKnownHostsFile ${knownHostsFile}`); + } else if (!settings.strictHostKeyChecking) { + lines.push(" UserKnownHostsFile /dev/null"); + } + if (identityFile) { + lines.push(` IdentityFile ${identityFile}`); + } + if (certificateFile) { + lines.push(` CertificateFile ${certificateFile}`); + } + if (identityFile || certificateFile) { + lines.push(" IdentitiesOnly yes"); + } + await fs.writeFile(configPath, `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.chmod(configPath, 0o600); + return { + command: settings.command.trim() || "ssh", + configPath, + host: hostAlias, + }; + } catch (error) { + await fs.rm(configDir, { recursive: true, force: true }); + throw error; + } +} + +export async function disposeSshSandboxSession(session: SshSandboxSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runSshSandboxCommand( + params: RunSshSandboxCommandParams, +): Promise { + const argv = buildSshSandboxArgv({ + session: params.session, + remoteCommand: params.remoteCommand, + tty: params.tty, + }); + return await new Promise((resolve, reject) => { + const child = spawn(argv[0], argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + reject( + Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ), + ); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); +} + +export async function uploadDirectoryToSshTarget(params: { + session: SshSandboxSession; + localDir: string; + remoteDir: string; + signal?: AbortSignal; +}): Promise { + const remoteCommand = buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && tar -xf - -C "$1"', + "openclaw-sandbox-upload", + params.remoteDir, + ]); + const sshArgv = buildSshSandboxArgv({ + session: params.session, + remoteCommand, + }); + await new Promise((resolve, reject) => { + const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], { + stdio: ["ignore", "pipe", "pipe"], + signal: params.signal, + }); + const ssh = spawn(sshArgv[0], sshArgv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const tarStderr: Buffer[] = []; + const sshStdout: Buffer[] = []; + const sshStderr: Buffer[] = []; + let tarClosed = false; + let sshClosed = false; + let tarCode = 0; + let sshCode = 0; + + tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk))); + ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk))); + ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk))); + + const fail = (error: unknown) => { + tar.kill("SIGKILL"); + ssh.kill("SIGKILL"); + reject(error); + }; + + tar.on("error", fail); + ssh.on("error", fail); + tar.stdout.pipe(ssh.stdin); + + tar.on("close", (code) => { + tarClosed = true; + tarCode = code ?? 0; + maybeResolve(); + }); + ssh.on("close", (code) => { + sshClosed = true; + sshCode = code ?? 0; + maybeResolve(); + }); + + function maybeResolve() { + if (!tarClosed || !sshClosed) { + return; + } + if (tarCode !== 0) { + reject( + new Error( + Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`, + ), + ); + return; + } + if (sshCode !== 0) { + reject( + new Error( + Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`, + ), + ); + return; + } + resolve(); + } + }); +} + +function parseSshConfigHost(configText: string): string | null { + const hostMatch = configText.match(/^\s*Host\s+(\S+)/m); + return hostMatch?.[1]?.trim() || null; +} + +function resolveSshTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} + +function resolveOptionalLocalPath(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? resolveUserPath(trimmed) : undefined; +} + +async function writeSecretMaterial( + dir: string, + filename: string, + contents: string, +): Promise { + const pathname = path.join(dir, filename); + await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(pathname, 0o600); + return pathname; +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 8244583ea0c..482ce6a922e 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -51,6 +51,20 @@ export type SandboxPruneConfig = { maxAgeDays: number; }; +export type SandboxSshConfig = { + target?: string; + command: string; + workspaceRoot: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { @@ -60,6 +74,7 @@ export type SandboxConfig = { workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; docker: SandboxDockerConfig; + ssh: SandboxSshConfig; browser: SandboxBrowserConfig; tools: SandboxToolPolicy; prune: SandboxPruneConfig; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 1e398cc1c70..3351d9903c9 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -2,6 +2,7 @@ import type { SandboxBrowserSettings, SandboxDockerSettings, SandboxPruneSettings, + SandboxSshSettings, } from "./types.sandbox.js"; export type AgentModelConfig = @@ -32,6 +33,8 @@ export type AgentSandboxConfig = { workspaceRoot?: string; /** Docker-specific sandbox settings. */ docker?: SandboxDockerSettings; + /** SSH-specific sandbox settings. */ + ssh?: SandboxSshSettings; /** Optional sandboxed browser settings. */ browser?: SandboxBrowserSettings; /** Auto-prune sandbox settings. */ diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 047f10cde53..04128e2ffaa 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -1,3 +1,5 @@ +import type { SecretInput } from "./types.secrets.js"; + export type SandboxDockerSettings = { /** Docker image to use for sandbox containers. */ image?: string; @@ -94,3 +96,28 @@ export type SandboxPruneSettings = { /** Prune if older than N days (0 disables). */ maxAgeDays?: number; }; + +export type SandboxSshSettings = { + /** SSH target in user@host[:port] form. */ + target?: string; + /** SSH client command. Default: "ssh". */ + command?: string; + /** Absolute remote root used for per-scope workspaces. */ + workspaceRoot?: string; + /** Enforce host-key verification. Default: true. */ + strictHostKeyChecking?: boolean; + /** Allow OpenSSH host-key updates. Default: true. */ + updateHostKeys?: boolean; + /** Existing private key path on the host. */ + identityFile?: string; + /** Existing SSH certificate path on the host. */ + certificateFile?: string; + /** Existing known_hosts file path on the host. */ + knownHostsFile?: string; + /** Inline or SecretRef-backed private key contents. */ + identityData?: SecretInput; + /** Inline or SecretRef-backed SSH certificate contents. */ + certificateData?: SecretInput; + /** Inline or SecretRef-backed known_hosts contents. */ + knownHostsData?: SecretInput; +}; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 9ddbedf929e..10cef396275 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -501,6 +501,23 @@ const ToolLoopDetectionSchema = z }) .optional(); +export const SandboxSshSchema = z + .object({ + target: z.string().min(1).optional(), + command: z.string().min(1).optional(), + workspaceRoot: z.string().min(1).optional(), + strictHostKeyChecking: z.boolean().optional(), + updateHostKeys: z.boolean().optional(), + identityFile: z.string().min(1).optional(), + certificateFile: z.string().min(1).optional(), + knownHostsFile: z.string().min(1).optional(), + identityData: SecretInputSchema.optional().register(sensitive), + certificateData: SecretInputSchema.optional().register(sensitive), + knownHostsData: SecretInputSchema.optional().register(sensitive), + }) + .strict() + .optional(); + export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), @@ -511,6 +528,7 @@ export const AgentSandboxSchema = z perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, + ssh: SandboxSshSchema, browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f3a6d1ca16b..025efaff67a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -31,6 +31,8 @@ export type { } from "../plugins/types.js"; export type { CreateSandboxBackendParams, + RemoteShellSandboxHandle, + RunSshSandboxCommandParams, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendExecSpec, @@ -44,6 +46,9 @@ export type { SandboxBackendRuntimeInfo, SandboxContext, SandboxResolvedPath, + SandboxSshConfig, + SshSandboxSession, + SshSandboxSettings, } from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -57,9 +62,19 @@ export type { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createRemoteShellSandboxFsBridge, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, getSandboxBackendFactory, getSandboxBackendManager, registerSandboxBackend, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, requireSandboxBackendFactory, } from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 99668371ad1..ef571b3f54f 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -313,6 +313,90 @@ function collectCronAssignments(params: { }); } +function collectSandboxSshAssignments(params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const agents = isRecord(params.config.agents) ? params.config.agents : undefined; + if (!agents) { + return; + } + const defaultsAgent = isRecord(agents.defaults) ? agents.defaults : undefined; + const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined; + const defaultsSsh = isRecord(defaultsSandbox?.ssh) + ? (defaultsSandbox.ssh as Record) + : undefined; + const defaultsBackend = + typeof defaultsSandbox?.backend === "string" ? defaultsSandbox.backend : undefined; + const defaultsMode = typeof defaultsSandbox?.mode === "string" ? defaultsSandbox.mode : undefined; + + const inheritedDefaultsUsage = { + identityData: false, + certificateData: false, + knownHostsData: false, + }; + + const list = Array.isArray(agents.list) ? agents.list : []; + list.forEach((rawAgent, index) => { + const agentRecord = isRecord(rawAgent) ? (rawAgent as Record) : null; + if (!agentRecord || agentRecord.enabled === false) { + return; + } + const sandbox = isRecord(agentRecord.sandbox) ? agentRecord.sandbox : undefined; + const ssh = isRecord(sandbox?.ssh) ? sandbox.ssh : undefined; + const effectiveBackend = + (typeof sandbox?.backend === "string" ? sandbox.backend : undefined) ?? + defaultsBackend ?? + "docker"; + const effectiveMode = + (typeof sandbox?.mode === "string" ? sandbox.mode : undefined) ?? defaultsMode ?? "off"; + const active = effectiveBackend.trim().toLowerCase() === "ssh" && effectiveMode !== "off"; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + if (ssh && Object.prototype.hasOwnProperty.call(ssh, key)) { + collectSecretInputAssignment({ + value: ssh[key], + path: `agents.list.${index}.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active, + inactiveReason: "sandbox SSH backend is not active for this agent.", + apply: (value) => { + ssh[key] = value; + }, + }); + } else if (active) { + inheritedDefaultsUsage[key] = true; + } + } + }); + + if (!defaultsSsh) { + return; + } + + const defaultsActive = + (defaultsBackend?.trim().toLowerCase() === "ssh" && defaultsMode !== "off") || + inheritedDefaultsUsage.identityData || + inheritedDefaultsUsage.certificateData || + inheritedDefaultsUsage.knownHostsData; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + collectSecretInputAssignment({ + value: defaultsSsh[key], + path: `agents.defaults.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: defaultsActive || inheritedDefaultsUsage[key], + inactiveReason: "sandbox SSH backend is not active.", + apply: (value) => { + defaultsSsh[key] = value; + }, + }); + } +} + export function collectCoreConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -339,6 +423,7 @@ export function collectCoreConfigAssignments(params: { collectAgentMemorySearchAssignments(params); collectTalkAssignments(params); collectGatewayAssignments(params); + collectSandboxSshAssignments(params); collectMessagesTtsAssignments(params); collectCronAssignments(params); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 47628f1bfe2..837a174efaa 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -221,6 +221,46 @@ describe("secrets runtime snapshot", () => { ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); }); + it("resolves sandbox ssh secret refs for active ssh backends", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "peter@example.com:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + certificateData: { + source: "env", + provider: "default", + id: "SSH_CERTIFICATE_DATA", + }, + knownHostsData: { + source: "env", + provider: "default", + id: "SSH_KNOWN_HOSTS_DATA", + }, + }, + }, + }, + }, + }), + env: { + SSH_IDENTITY_DATA: "PRIVATE KEY", + SSH_CERTIFICATE_DATA: "SSH CERT", + SSH_KNOWN_HOSTS_DATA: "example.com ssh-ed25519 AAAATEST", + }, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh).toMatchObject({ + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 0a2f95916be6354d6e898ec3b8eb45015e16f16a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:38:22 -0700 Subject: [PATCH 111/331] test: expand ssh sandbox coverage and docs --- docs/cli/sandbox.md | 6 + docs/gateway/configuration-reference.md | 7 + docs/gateway/sandboxing.md | 12 + docs/gateway/secrets.md | 29 ++ src/agents/sandbox/ssh-backend.test.ts | 338 ++++++++++++++++++++++++ src/secrets/runtime.test.ts | 33 +++ 6 files changed, 425 insertions(+) create mode 100644 src/agents/sandbox/ssh-backend.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f320be3b771..5764851dc70 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -19,6 +19,12 @@ Today that usually means: - SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` +For `ssh` and OpenShell `remote`, recreate matters more than with Docker: + +- the remote workspace is canonical after the initial seed +- `openclaw sandbox recreate` deletes that canonical remote workspace for the selected scope +- next use seeds it again from the current local workspace + ## Commands ### `openclaw sandbox explain` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ecefd8bbc4e..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1232,6 +1232,13 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime - `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs +**SSH auth precedence:** + +- `identityData` wins over `identityFile` +- `certificateData` wins over `certificateFile` +- `knownHostsData` wins over `knownHostsFile` +- SecretRef-backed `*Data` values are resolved from the active secrets runtime snapshot before the sandbox session starts + **SSH backend behavior:** - seeds the remote workspace once after create or recreate diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index b37757334c0..c6cf839e42d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -105,6 +105,12 @@ How it works: - After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. - OpenClaw does not sync remote changes back to the local workspace automatically. +Authentication material: + +- `identityFile`, `certificateFile`, `knownHostsFile`: use existing local files and pass them through OpenSSH config. +- `identityData`, `certificateData`, `knownHostsData`: use inline strings or SecretRefs. OpenClaw resolves them through the normal secrets runtime snapshot, writes them to temp files with `0600`, and deletes them when the SSH session ends. +- If both `*File` and `*Data` are set for the same item, `*Data` wins for that SSH session. + This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. Important consequences: @@ -150,6 +156,12 @@ OpenShell modes: OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. +Remote transport details: + +- OpenClaw asks OpenShell for sandbox-specific SSH config via `openshell sandbox ssh-config `. +- Core writes that SSH config to a temp file, opens the SSH session, and reuses the same remote filesystem bridge used by `backend: "ssh"`. +- In `mirror` mode only the lifecycle differs: sync local to remote before exec, then sync back after exec. + Current OpenShell limitations: - sandbox browser is not supported yet diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index eb044eaf03c..05554b1f6d3 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -288,6 +288,35 @@ Optional per-id errors: } ``` +## Sandbox SSH auth material + +The core `ssh` sandbox backend also supports SecretRefs for SSH auth material: + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "user@gateway-host:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +Runtime behavior: + +- OpenClaw resolves these refs during sandbox activation, not lazily during each SSH call. +- Resolved values are written to temp files with restrictive permissions and used in generated SSH config. +- If the effective sandbox backend is not `ssh`, these refs stay inactive and do not block startup. + ## Supported credential surface Canonical supported and unsupported credentials are listed in: diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts new file mode 100644 index 00000000000..c8ec3b5f750 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -0,0 +1,338 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const sshMocks = vi.hoisted(() => ({ + createSshSandboxSessionFromSettings: vi.fn(), + disposeSshSandboxSession: vi.fn(), + runSshSandboxCommand: vi.fn(), + uploadDirectoryToSshTarget: vi.fn(), + buildSshSandboxArgv: vi.fn(), +})); + +vi.mock("./ssh.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSshSandboxSessionFromSettings: sshMocks.createSshSandboxSessionFromSettings, + disposeSshSandboxSession: sshMocks.disposeSshSandboxSession, + runSshSandboxCommand: sshMocks.runSshSandboxCommand, + uploadDirectoryToSshTarget: sshMocks.uploadDirectoryToSshTarget, + buildSshSandboxArgv: sshMocks.buildSshSandboxArgv, + }; +}); + +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; + +function createConfig(): OpenClawConfig { + return { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + }, + }, + }, + }; +} + +function createSession() { + return { + command: "ssh", + configPath: path.join(os.tmpdir(), "openclaw-test-ssh-config"), + host: "openclaw-sandbox", + }; +} + +describe("ssh sandbox backend", () => { + beforeEach(() => { + vi.clearAllMocks(); + sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession()); + sshMocks.disposeSshSandboxSession.mockResolvedValue(undefined); + sshMocks.runSshSandboxCommand.mockResolvedValue({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }); + sshMocks.uploadDirectoryToSshTarget.mockResolvedValue(undefined); + sshMocks.buildSshSandboxArgv.mockImplementation(({ session, remoteCommand, tty }) => [ + session.command, + "-F", + session.configPath, + tty ? "-tt" : "-T", + session.host, + remoteCommand, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("describes runtimes via the configured ssh target", async () => { + const result = await sshSandboxBackendManager.describeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "peter@example.com:2222", + configLabelMatch: true, + }); + expect(sshMocks.createSshSandboxSessionFromSettings).toHaveBeenCalledWith( + expect.objectContaining({ + target: "peter@example.com:2222", + workspaceRoot: "/remote/openclaw", + }), + ); + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + remoteCommand: expect.stringContaining("/remote/openclaw/openclaw-ssh-agent-worker"), + }), + ); + }); + + it("removes runtimes by deleting the remote scope root", async () => { + await sshSandboxBackendManager.removeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: true, + remoteCommand: expect.stringContaining('rm -rf -- "$1"'), + }), + ); + }); + + it("creates a remote-canonical backend that seeds once and reuses ssh exec", async () => { + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("0\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/agent", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "openclaw-browser", + containerPrefix: "openclaw-browser-", + network: "bridge", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1000, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }); + + const execSpec = await backend.buildExecSpec({ + command: "pwd", + env: { TEST_TOKEN: "1" }, + usePty: false, + }); + + expect(execSpec.argv).toEqual( + expect.arrayContaining(["ssh", "-F", createSession().configPath, "-T", createSession().host]), + ); + expect(execSpec.argv.at(-1)).toContain("/remote/openclaw/openclaw-ssh-agent-worker"); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + localDir: "/tmp/workspace", + remoteDir: expect.stringContaining("/workspace"), + }), + ); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + localDir: "/tmp/agent", + remoteDir: expect.stringContaining("/agent"), + }), + ); + + await backend.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: execSpec.finalizeToken, + }); + expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalled(); + }); + + it("rejects docker binds and missing ssh target", async () => { + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + binds: ["/tmp:/tmp:rw"], + }, + ssh: { + target: "peter@example.com:22", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("does not support sandbox.docker.binds"); + + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + }, + ssh: { + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("requires agents.defaults.sandbox.ssh.target"); + }); +}); diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 837a174efaa..8e7e549ae51 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -261,6 +261,39 @@ describe("secrets runtime snapshot", () => { }); }); + it("treats sandbox ssh secret refs as inactive when ssh backend is not selected", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + ssh: { + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + }, + }, + }, + }, + }), + env: {}, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toEqual({ + source: "env", + provider: "default", + id: "SSH_IDENTITY_DATA", + }); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "agents.defaults.sandbox.ssh.identityData", + }), + ]), + ); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 1beea52d8dfd8c9248a24fa5bc982d78e4d7396a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:37:19 -0700 Subject: [PATCH 112/331] refactor: rename setup wizard surfaces --- src/canvas-host/a2ui/a2ui.bundle.js | 15272 ++++++++++++++++++++++++++ 1 file changed, 15272 insertions(+) create mode 100644 src/canvas-host/a2ui/a2ui.bundle.js diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js new file mode 100644 index 00000000000..d12450da71f --- /dev/null +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -0,0 +1,15272 @@ +var __defProp$1 = Object.defineProperty; +var __exportAll = (all, no_symbols) => { + let target = {}; + for (var name in all) + __defProp$1(target, name, { + get: all[name], + enumerable: true, + }); + if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" }); + return target; +}; +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$6 = globalThis, + e$13 = + t$6.ShadowRoot && + (void 0 === t$6.ShadyCSS || t$6.ShadyCSS.nativeShadow) && + "adoptedStyleSheets" in Document.prototype && + "replace" in CSSStyleSheet.prototype, + s$8 = Symbol(), + o$14 = /* @__PURE__ */ new WeakMap(); +var n$12 = class { + constructor(t, e, o) { + if (((this._$cssResult$ = !0), o !== s$8)) + throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + ((this.cssText = t), (this.t = e)); + } + get styleSheet() { + let t = this.o; + const s = this.t; + if (e$13 && void 0 === t) { + const e = void 0 !== s && 1 === s.length; + (e && (t = o$14.get(s)), + void 0 === t && + ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), e && o$14.set(s, t))); + } + return t; + } + toString() { + return this.cssText; + } +}; +const r$11 = (t) => new n$12("string" == typeof t ? t : t + "", void 0, s$8), + i$9 = (t, ...e) => { + return new n$12( + 1 === t.length + ? t[0] + : e.reduce( + (e, s, o) => + e + + ((t) => { + if (!0 === t._$cssResult$) return t.cssText; + if ("number" == typeof t) return t; + throw Error( + "Value passed to 'css' function must be a 'css' function result: " + + t + + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.", + ); + })(s) + + t[o + 1], + t[0], + ), + t, + s$8, + ); + }, + S$1 = (s, o) => { + if (e$13) s.adoptedStyleSheets = o.map((t) => (t instanceof CSSStyleSheet ? t : t.styleSheet)); + else + for (const e of o) { + const o = document.createElement("style"), + n = t$6.litNonce; + (void 0 !== n && o.setAttribute("nonce", n), (o.textContent = e.cssText), s.appendChild(o)); + } + }, + c$6 = e$13 + ? (t) => t + : (t) => + t instanceof CSSStyleSheet + ? ((t) => { + let e = ""; + for (const s of t.cssRules) e += s.cssText; + return r$11(e); + })(t) + : t; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { + is: i$8, + defineProperty: e$12, + getOwnPropertyDescriptor: h$6, + getOwnPropertyNames: r$10, + getOwnPropertySymbols: o$13, + getPrototypeOf: n$11, + } = Object, + a$1 = globalThis, + c$5 = a$1.trustedTypes, + l$4 = c$5 ? c$5.emptyScript : "", + p$2 = a$1.reactiveElementPolyfillSupport, + d$2 = (t, s) => t, + u$3 = { + toAttribute(t, s) { + switch (s) { + case Boolean: + t = t ? l$4 : null; + break; + case Object: + case Array: + t = null == t ? t : JSON.stringify(t); + } + return t; + }, + fromAttribute(t, s) { + let i = t; + switch (s) { + case Boolean: + i = null !== t; + break; + case Number: + i = null === t ? null : Number(t); + break; + case Object: + case Array: + try { + i = JSON.parse(t); + } catch (t) { + i = null; + } + } + return i; + }, + }, + f$3 = (t, s) => !i$8(t, s), + b$1 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + useDefault: !1, + hasChanged: f$3, + }; +((Symbol.metadata ??= Symbol("metadata")), + (a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap())); +var y$1 = class extends HTMLElement { + static addInitializer(t) { + (this._$Ei(), (this.l ??= []).push(t)); + } + static get observedAttributes() { + return (this.finalize(), this._$Eh && [...this._$Eh.keys()]); + } + static createProperty(t, s = b$1) { + if ( + (s.state && (s.attribute = !1), + this._$Ei(), + this.prototype.hasOwnProperty(t) && ((s = Object.create(s)).wrapped = !0), + this.elementProperties.set(t, s), + !s.noAccessor) + ) { + const i = Symbol(), + h = this.getPropertyDescriptor(t, i, s); + void 0 !== h && e$12(this.prototype, t, h); + } + } + static getPropertyDescriptor(t, s, i) { + const { get: e, set: r } = h$6(this.prototype, t) ?? { + get() { + return this[s]; + }, + set(t) { + this[s] = t; + }, + }; + return { + get: e, + set(s) { + const h = e?.call(this); + (r?.call(this, s), this.requestUpdate(t, h, i)); + }, + configurable: !0, + enumerable: !0, + }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? b$1; + } + static _$Ei() { + if (this.hasOwnProperty(d$2("elementProperties"))) return; + const t = n$11(this); + (t.finalize(), + void 0 !== t.l && (this.l = [...t.l]), + (this.elementProperties = new Map(t.elementProperties))); + } + static finalize() { + if (this.hasOwnProperty(d$2("finalized"))) return; + if (((this.finalized = !0), this._$Ei(), this.hasOwnProperty(d$2("properties")))) { + const t = this.properties, + s = [...r$10(t), ...o$13(t)]; + for (const i of s) this.createProperty(i, t[i]); + } + const t = this[Symbol.metadata]; + if (null !== t) { + const s = litPropertyMetadata.get(t); + if (void 0 !== s) for (const [t, i] of s) this.elementProperties.set(t, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [t, s] of this.elementProperties) { + const i = this._$Eu(t, s); + void 0 !== i && this._$Eh.set(i, t); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s) { + const i = []; + if (Array.isArray(s)) { + const e = new Set(s.flat(Infinity).reverse()); + for (const s of e) i.unshift(c$6(s)); + } else void 0 !== s && i.push(c$6(s)); + return i; + } + static _$Eu(t, s) { + const i = s.attribute; + return !1 === i + ? void 0 + : "string" == typeof i + ? i + : "string" == typeof t + ? t.toLowerCase() + : void 0; + } + constructor() { + (super(), + (this._$Ep = void 0), + (this.isUpdatePending = !1), + (this.hasUpdated = !1), + (this._$Em = null), + this._$Ev()); + } + _$Ev() { + ((this._$ES = new Promise((t) => (this.enableUpdating = t))), + (this._$AL = /* @__PURE__ */ new Map()), + this._$E_(), + this.requestUpdate(), + this.constructor.l?.forEach((t) => t(this))); + } + addController(t) { + ((this._$EO ??= /* @__PURE__ */ new Set()).add(t), + void 0 !== this.renderRoot && this.isConnected && t.hostConnected?.()); + } + removeController(t) { + this._$EO?.delete(t); + } + _$E_() { + const t = /* @__PURE__ */ new Map(), + s = this.constructor.elementProperties; + for (const i of s.keys()) this.hasOwnProperty(i) && (t.set(i, this[i]), delete this[i]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return (S$1(t, this.constructor.elementStyles), t); + } + connectedCallback() { + ((this.renderRoot ??= this.createRenderRoot()), + this.enableUpdating(!0), + this._$EO?.forEach((t) => t.hostConnected?.())); + } + enableUpdating(t) {} + disconnectedCallback() { + this._$EO?.forEach((t) => t.hostDisconnected?.()); + } + attributeChangedCallback(t, s, i) { + this._$AK(t, i); + } + _$ET(t, s) { + const i = this.constructor.elementProperties.get(t), + e = this.constructor._$Eu(t, i); + if (void 0 !== e && !0 === i.reflect) { + const h = (void 0 !== i.converter?.toAttribute ? i.converter : u$3).toAttribute(s, i.type); + ((this._$Em = t), + null == h ? this.removeAttribute(e) : this.setAttribute(e, h), + (this._$Em = null)); + } + } + _$AK(t, s) { + const i = this.constructor, + e = i._$Eh.get(t); + if (void 0 !== e && this._$Em !== e) { + const t = i.getPropertyOptions(e), + h = + "function" == typeof t.converter + ? { fromAttribute: t.converter } + : void 0 !== t.converter?.fromAttribute + ? t.converter + : u$3; + this._$Em = e; + const r = h.fromAttribute(s, t.type); + ((this[e] = r ?? this._$Ej?.get(e) ?? r), (this._$Em = null)); + } + } + requestUpdate(t, s, i, e = !1, h) { + if (void 0 !== t) { + const r = this.constructor; + if ( + (!1 === e && (h = this[t]), + (i ??= r.getPropertyOptions(t)), + !( + (i.hasChanged ?? f$3)(h, s) || + (i.useDefault && i.reflect && h === this._$Ej?.get(t) && !this.hasAttribute(r._$Eu(t, i))) + )) + ) + return; + this.C(t, s, i); + } + !1 === this.isUpdatePending && (this._$ES = this._$EP()); + } + C(t, s, { useDefault: i, reflect: e, wrapped: h }, r) { + (i && + !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && + (this._$Ej.set(t, r ?? s ?? this[t]), !0 !== h || void 0 !== r)) || + (this._$AL.has(t) || (this.hasUpdated || i || (s = void 0), this._$AL.set(t, s)), + !0 === e && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (t) { + Promise.reject(t); + } + const t = this.scheduleUpdate(); + return (null != t && (await t), !this.isUpdatePending); + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (((this.renderRoot ??= this.createRenderRoot()), this._$Ep)) { + for (const [t, s] of this._$Ep) this[t] = s; + this._$Ep = void 0; + } + const t = this.constructor.elementProperties; + if (t.size > 0) + for (const [s, i] of t) { + const { wrapped: t } = i, + e = this[s]; + !0 !== t || this._$AL.has(s) || void 0 === e || this.C(s, void 0, i, e); + } + } + let t = !1; + const s = this._$AL; + try { + ((t = this.shouldUpdate(s)), + t + ? (this.willUpdate(s), this._$EO?.forEach((t) => t.hostUpdate?.()), this.update(s)) + : this._$EM()); + } catch (s) { + throw ((t = !1), this._$EM(), s); + } + t && this._$AE(s); + } + willUpdate(t) {} + _$AE(t) { + (this._$EO?.forEach((t) => t.hostUpdated?.()), + this.hasUpdated || ((this.hasUpdated = !0), this.firstUpdated(t)), + this.updated(t)); + } + _$EM() { + ((this._$AL = /* @__PURE__ */ new Map()), (this.isUpdatePending = !1)); + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t) { + return !0; + } + update(t) { + ((this._$Eq &&= this._$Eq.forEach((t) => this._$ET(t, this[t]))), this._$EM()); + } + updated(t) {} + firstUpdated(t) {} +}; +((y$1.elementStyles = []), + (y$1.shadowRootOptions = { mode: "open" }), + (y$1[d$2("elementProperties")] = /* @__PURE__ */ new Map()), + (y$1[d$2("finalized")] = /* @__PURE__ */ new Map()), + p$2?.({ ReactiveElement: y$1 }), + (a$1.reactiveElementVersions ??= []).push("2.1.2")); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$5 = globalThis, + i$7 = (t) => t, + s$7 = t$5.trustedTypes, + e$11 = s$7 ? s$7.createPolicy("lit-html", { createHTML: (t) => t }) : void 0, + h$5 = "$lit$", + o$12 = `lit$${Math.random().toFixed(9).slice(2)}$`, + n$10 = "?" + o$12, + r$9 = `<${n$10}>`, + l$3 = document, + c$4 = () => l$3.createComment(""), + a = (t) => null === t || ("object" != typeof t && "function" != typeof t), + u$2 = Array.isArray, + d$1 = (t) => u$2(t) || "function" == typeof t?.[Symbol.iterator], + f$2 = "[ \n\f\r]", + v$1 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, + _ = /-->/g, + m$2 = />/g, + p$1 = RegExp(`>|${f$2}(?:([^\\s"'>=/]+)(${f$2}*=${f$2}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), + g = /'/g, + $ = /"/g, + y = /^(?:script|style|textarea|title)$/i, + x = + (t) => + (i, ...s) => ({ + _$litType$: t, + strings: i, + values: s, + }), + b = x(1), + w = x(2); +x(3); +const E = Symbol.for("lit-noChange"), + A = Symbol.for("lit-nothing"), + C = /* @__PURE__ */ new WeakMap(), + P = l$3.createTreeWalker(l$3, 129); +function V(t, i) { + if (!u$2(t) || !t.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return void 0 !== e$11 ? e$11.createHTML(i) : i; +} +const N = (t, i) => { + const s = t.length - 1, + e = []; + let n, + l = 2 === i ? "" : 3 === i ? "" : "", + c = v$1; + for (let i = 0; i < s; i++) { + const s = t[i]; + let a, + u, + d = -1, + f = 0; + for (; f < s.length && ((c.lastIndex = f), (u = c.exec(s)), null !== u); ) + ((f = c.lastIndex), + c === v$1 + ? "!--" === u[1] + ? (c = _) + : void 0 !== u[1] + ? (c = m$2) + : void 0 !== u[2] + ? (y.test(u[2]) && (n = RegExp("" === u[0] + ? ((c = n ?? v$1), (d = -1)) + : void 0 === u[1] + ? (d = -2) + : ((d = c.lastIndex - u[2].length), + (a = u[1]), + (c = void 0 === u[3] ? p$1 : '"' === u[3] ? $ : g)) + : c === $ || c === g + ? (c = p$1) + : c === _ || c === m$2 + ? (c = v$1) + : ((c = p$1), (n = void 0))); + const x = c === p$1 && t[i + 1].startsWith("/>") ? " " : ""; + l += + c === v$1 + ? s + r$9 + : d >= 0 + ? (e.push(a), s.slice(0, d) + h$5 + s.slice(d) + o$12 + x) + : s + o$12 + (-2 === d ? i : x); + } + return [V(t, l + (t[s] || "") + (2 === i ? "" : 3 === i ? "" : "")), e]; +}; +var S = class S { + constructor({ strings: t, _$litType$: i }, e) { + let r; + this.parts = []; + let l = 0, + a = 0; + const u = t.length - 1, + d = this.parts, + [f, v] = N(t, i); + if ( + ((this.el = S.createElement(f, e)), (P.currentNode = this.el.content), 2 === i || 3 === i) + ) { + const t = this.el.content.firstChild; + t.replaceWith(...t.childNodes); + } + for (; null !== (r = P.nextNode()) && d.length < u; ) { + if (1 === r.nodeType) { + if (r.hasAttributes()) + for (const t of r.getAttributeNames()) + if (t.endsWith(h$5)) { + const i = v[a++], + s = r.getAttribute(t).split(o$12), + e = /([.?@])?(.*)/.exec(i); + (d.push({ + type: 1, + index: l, + name: e[2], + strings: s, + ctor: "." === e[1] ? I : "?" === e[1] ? L : "@" === e[1] ? z : H, + }), + r.removeAttribute(t)); + } else + t.startsWith(o$12) && + (d.push({ + type: 6, + index: l, + }), + r.removeAttribute(t)); + if (y.test(r.tagName)) { + const t = r.textContent.split(o$12), + i = t.length - 1; + if (i > 0) { + r.textContent = s$7 ? s$7.emptyScript : ""; + for (let s = 0; s < i; s++) + (r.append(t[s], c$4()), + P.nextNode(), + d.push({ + type: 2, + index: ++l, + })); + r.append(t[i], c$4()); + } + } + } else if (8 === r.nodeType) + if (r.data === n$10) + d.push({ + type: 2, + index: l, + }); + else { + let t = -1; + for (; -1 !== (t = r.data.indexOf(o$12, t + 1)); ) + (d.push({ + type: 7, + index: l, + }), + (t += o$12.length - 1)); + } + l++; + } + } + static createElement(t, i) { + const s = l$3.createElement("template"); + return ((s.innerHTML = t), s); + } +}; +function M$1(t, i, s = t, e) { + if (i === E) return i; + let h = void 0 !== e ? s._$Co?.[e] : s._$Cl; + const o = a(i) ? void 0 : i._$litDirective$; + return ( + h?.constructor !== o && + (h?._$AO?.(!1), + void 0 === o ? (h = void 0) : ((h = new o(t)), h._$AT(t, s, e)), + void 0 !== e ? ((s._$Co ??= [])[e] = h) : (s._$Cl = h)), + void 0 !== h && (i = M$1(t, h._$AS(t, i.values), h, e)), + i + ); +} +var R = class { + constructor(t, i) { + ((this._$AV = []), (this._$AN = void 0), (this._$AD = t), (this._$AM = i)); + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t) { + const { + el: { content: i }, + parts: s, + } = this._$AD, + e = (t?.creationScope ?? l$3).importNode(i, !0); + P.currentNode = e; + let h = P.nextNode(), + o = 0, + n = 0, + r = s[0]; + for (; void 0 !== r; ) { + if (o === r.index) { + let i; + (2 === r.type + ? (i = new k(h, h.nextSibling, this, t)) + : 1 === r.type + ? (i = new r.ctor(h, r.name, r.strings, this, t)) + : 6 === r.type && (i = new Z(h, this, t)), + this._$AV.push(i), + (r = s[++n])); + } + o !== r?.index && ((h = P.nextNode()), o++); + } + return ((P.currentNode = l$3), e); + } + p(t) { + let i = 0; + for (const s of this._$AV) + (void 0 !== s && + (void 0 !== s.strings ? (s._$AI(t, s, i), (i += s.strings.length - 2)) : s._$AI(t[i])), + i++); + } +}; +var k = class k { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, i, s, e) { + ((this.type = 2), + (this._$AH = A), + (this._$AN = void 0), + (this._$AA = t), + (this._$AB = i), + (this._$AM = s), + (this.options = e), + (this._$Cv = e?.isConnected ?? !0)); + } + get parentNode() { + let t = this._$AA.parentNode; + const i = this._$AM; + return (void 0 !== i && 11 === t?.nodeType && (t = i.parentNode), t); + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t, i = this) { + ((t = M$1(this, t, i)), + a(t) + ? t === A || null == t || "" === t + ? (this._$AH !== A && this._$AR(), (this._$AH = A)) + : t !== this._$AH && t !== E && this._(t) + : void 0 !== t._$litType$ + ? this.$(t) + : void 0 !== t.nodeType + ? this.T(t) + : d$1(t) + ? this.k(t) + : this._(t)); + } + O(t) { + return this._$AA.parentNode.insertBefore(t, this._$AB); + } + T(t) { + this._$AH !== t && (this._$AR(), (this._$AH = this.O(t))); + } + _(t) { + (this._$AH !== A && a(this._$AH) + ? (this._$AA.nextSibling.data = t) + : this.T(l$3.createTextNode(t)), + (this._$AH = t)); + } + $(t) { + const { values: i, _$litType$: s } = t, + e = + "number" == typeof s + ? this._$AC(t) + : (void 0 === s.el && (s.el = S.createElement(V(s.h, s.h[0]), this.options)), s); + if (this._$AH?._$AD === e) this._$AH.p(i); + else { + const t = new R(e, this), + s = t.u(this.options); + (t.p(i), this.T(s), (this._$AH = t)); + } + } + _$AC(t) { + let i = C.get(t.strings); + return (void 0 === i && C.set(t.strings, (i = new S(t))), i); + } + k(t) { + u$2(this._$AH) || ((this._$AH = []), this._$AR()); + const i = this._$AH; + let s, + e = 0; + for (const h of t) + (e === i.length + ? i.push((s = new k(this.O(c$4()), this.O(c$4()), this, this.options))) + : (s = i[e]), + s._$AI(h), + e++); + e < i.length && (this._$AR(s && s._$AB.nextSibling, e), (i.length = e)); + } + _$AR(t = this._$AA.nextSibling, s) { + for (this._$AP?.(!1, !0, s); t !== this._$AB; ) { + const s = i$7(t).nextSibling; + (i$7(t).remove(), (t = s)); + } + } + setConnected(t) { + void 0 === this._$AM && ((this._$Cv = t), this._$AP?.(t)); + } +}; +var H = class { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, i, s, e, h) { + ((this.type = 1), + (this._$AH = A), + (this._$AN = void 0), + (this.element = t), + (this.name = i), + (this._$AM = e), + (this.options = h), + s.length > 2 || "" !== s[0] || "" !== s[1] + ? ((this._$AH = Array(s.length - 1).fill(/* @__PURE__ */ new String())), (this.strings = s)) + : (this._$AH = A)); + } + _$AI(t, i = this, s, e) { + const h = this.strings; + let o = !1; + if (void 0 === h) + ((t = M$1(this, t, i, 0)), (o = !a(t) || (t !== this._$AH && t !== E)), o && (this._$AH = t)); + else { + const e = t; + let n, r; + for (t = h[0], n = 0; n < h.length - 1; n++) + ((r = M$1(this, e[s + n], i, n)), + r === E && (r = this._$AH[n]), + (o ||= !a(r) || r !== this._$AH[n]), + r === A ? (t = A) : t !== A && (t += (r ?? "") + h[n + 1]), + (this._$AH[n] = r)); + } + o && !e && this.j(t); + } + j(t) { + t === A + ? this.element.removeAttribute(this.name) + : this.element.setAttribute(this.name, t ?? ""); + } +}; +var I = class extends H { + constructor() { + (super(...arguments), (this.type = 3)); + } + j(t) { + this.element[this.name] = t === A ? void 0 : t; + } +}; +var L = class extends H { + constructor() { + (super(...arguments), (this.type = 4)); + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== A); + } +}; +var z = class extends H { + constructor(t, i, s, e, h) { + (super(t, i, s, e, h), (this.type = 5)); + } + _$AI(t, i = this) { + if ((t = M$1(this, t, i, 0) ?? A) === E) return; + const s = this._$AH, + e = + (t === A && s !== A) || + t.capture !== s.capture || + t.once !== s.once || + t.passive !== s.passive, + h = t !== A && (s === A || e); + (e && this.element.removeEventListener(this.name, this, s), + h && this.element.addEventListener(this.name, this, t), + (this._$AH = t)); + } + handleEvent(t) { + "function" == typeof this._$AH + ? this._$AH.call(this.options?.host ?? this.element, t) + : this._$AH.handleEvent(t); + } +}; +var Z = class { + constructor(t, i, s) { + ((this.element = t), + (this.type = 6), + (this._$AN = void 0), + (this._$AM = i), + (this.options = s)); + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + M$1(this, t); + } +}; +const j$1 = { + M: h$5, + P: o$12, + A: n$10, + C: 1, + L: N, + R, + D: d$1, + V: M$1, + I: k, + H, + N: L, + U: z, + B: I, + F: Z, + }, + B = t$5.litHtmlPolyfillSupport; +(B?.(S, k), (t$5.litHtmlVersions ??= []).push("3.3.2")); +const D = (t, i, s) => { + const e = s?.renderBefore ?? i; + let h = e._$litPart$; + if (void 0 === h) { + const t = s?.renderBefore ?? null; + e._$litPart$ = h = new k(i.insertBefore(c$4(), t), t, void 0, s ?? {}); + } + return (h._$AI(t), h); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s$6 = globalThis; +var i$6 = class extends y$1 { + constructor() { + (super(...arguments), (this.renderOptions = { host: this }), (this._$Do = void 0)); + } + createRenderRoot() { + const t = super.createRenderRoot(); + return ((this.renderOptions.renderBefore ??= t.firstChild), t); + } + update(t) { + const r = this.render(); + (this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), + super.update(t), + (this._$Do = D(r, this.renderRoot, this.renderOptions))); + } + connectedCallback() { + (super.connectedCallback(), this._$Do?.setConnected(!0)); + } + disconnectedCallback() { + (super.disconnectedCallback(), this._$Do?.setConnected(!1)); + } + render() { + return E; + } +}; +((i$6._$litElement$ = !0), + (i$6["finalized"] = !0), + s$6.litElementHydrateSupport?.({ LitElement: i$6 })); +const o$11 = s$6.litElementPolyfillSupport; +o$11?.({ LitElement: i$6 }); +(s$6.litElementVersions ??= []).push("4.2.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$4 = { + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6, + }, + e$10 = + (t) => + (...e) => ({ + _$litDirective$: t, + values: e, + }); +var i$5 = class { + constructor(t) {} + get _$AU() { + return this._$AM._$AU; + } + _$AT(t, e, i) { + ((this._$Ct = t), (this._$AM = e), (this._$Ci = i)); + } + _$AS(t, e) { + return this.update(t, e); + } + update(t, e) { + return this.render(...e); + } +}; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { I: t$3 } = j$1, + i$4 = (o) => o, + r$8 = (o) => void 0 === o.strings, + s$5 = () => document.createComment(""), + v = (o, n, e) => { + const l = o._$AA.parentNode, + d = void 0 === n ? o._$AB : n._$AA; + if (void 0 === e) e = new t$3(l.insertBefore(s$5(), d), l.insertBefore(s$5(), d), o, o.options); + else { + const t = e._$AB.nextSibling, + n = e._$AM, + c = n !== o; + if (c) { + let t; + (e._$AQ?.(o), (e._$AM = o), void 0 !== e._$AP && (t = o._$AU) !== n._$AU && e._$AP(t)); + } + if (t !== d || c) { + let o = e._$AA; + for (; o !== t; ) { + const t = i$4(o).nextSibling; + (i$4(l).insertBefore(o, d), (o = t)); + } + } + } + return e; + }, + u$1 = (o, t, i = o) => (o._$AI(t, i), o), + m$1 = {}, + p = (o, t = m$1) => (o._$AH = t), + M = (o) => o._$AH, + h$4 = (o) => { + (o._$AR(), o._$AA.remove()); + }; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const u = (e, s, t) => { + const r = /* @__PURE__ */ new Map(); + for (let l = s; l <= t; l++) r.set(e[l], l); + return r; + }, + c$2 = e$10( + class extends i$5 { + constructor(e) { + if ((super(e), e.type !== t$4.CHILD)) + throw Error("repeat() can only be used in text expressions"); + } + dt(e, s, t) { + let r; + void 0 === t ? (t = s) : void 0 !== s && (r = s); + const l = [], + o = []; + let i = 0; + for (const s of e) ((l[i] = r ? r(s, i) : i), (o[i] = t(s, i)), i++); + return { + values: o, + keys: l, + }; + } + render(e, s, t) { + return this.dt(e, s, t).values; + } + update(s, [t, r, c]) { + const d = M(s), + { values: p$3, keys: a } = this.dt(t, r, c); + if (!Array.isArray(d)) return ((this.ut = a), p$3); + const h = (this.ut ??= []), + v$2 = []; + let m, + y, + x = 0, + j = d.length - 1, + k = 0, + w = p$3.length - 1; + for (; x <= j && k <= w; ) + if (null === d[x]) x++; + else if (null === d[j]) j--; + else if (h[x] === a[k]) ((v$2[k] = u$1(d[x], p$3[k])), x++, k++); + else if (h[j] === a[w]) ((v$2[w] = u$1(d[j], p$3[w])), j--, w--); + else if (h[x] === a[w]) ((v$2[w] = u$1(d[x], p$3[w])), v(s, v$2[w + 1], d[x]), x++, w--); + else if (h[j] === a[k]) ((v$2[k] = u$1(d[j], p$3[k])), v(s, d[x], d[j]), j--, k++); + else if ((void 0 === m && ((m = u(a, k, w)), (y = u(h, x, j))), m.has(h[x]))) + if (m.has(h[j])) { + const e = y.get(a[k]), + t = void 0 !== e ? d[e] : null; + if (null === t) { + const e = v(s, d[x]); + (u$1(e, p$3[k]), (v$2[k] = e)); + } else ((v$2[k] = u$1(t, p$3[k])), v(s, d[x], t), (d[e] = null)); + k++; + } else (h$4(d[j]), j--); + else (h$4(d[x]), x++); + for (; k <= w; ) { + const e = v(s, v$2[w + 1]); + (u$1(e, p$3[k]), (v$2[k++] = e)); + } + for (; x <= j; ) { + const e = d[x++]; + null !== e && h$4(e); + } + return ((this.ut = a), p(s, v$2), E); + } + }, + ); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$4 = class extends Event { + constructor(s, t, e, o) { + (super("context-request", { + bubbles: !0, + composed: !0, + }), + (this.context = s), + (this.contextTarget = t), + (this.callback = e), + (this.subscribe = o ?? !1)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function n$7(n) { + return n; +} +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var s$3 = class { + constructor(t, s, i, h) { + if ( + ((this.subscribe = !1), + (this.provided = !1), + (this.value = void 0), + (this.t = (t, s) => { + (this.unsubscribe && + (this.unsubscribe !== s && ((this.provided = !1), this.unsubscribe()), + this.subscribe || this.unsubscribe()), + (this.value = t), + this.host.requestUpdate(), + (this.provided && !this.subscribe) || + ((this.provided = !0), this.callback && this.callback(t, s)), + (this.unsubscribe = s)); + }), + (this.host = t), + void 0 !== s.context) + ) { + const t = s; + ((this.context = t.context), + (this.callback = t.callback), + (this.subscribe = t.subscribe ?? !1)); + } else ((this.context = s), (this.callback = i), (this.subscribe = h ?? !1)); + this.host.addController(this); + } + hostConnected() { + this.dispatchRequest(); + } + hostDisconnected() { + this.unsubscribe && (this.unsubscribe(), (this.unsubscribe = void 0)); + } + dispatchRequest() { + this.host.dispatchEvent(new s$4(this.context, this.host, this.t, this.subscribe)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$2 = class { + get value() { + return this.o; + } + set value(s) { + this.setValue(s); + } + setValue(s, t = !1) { + const i = t || !Object.is(s, this.o); + ((this.o = s), i && this.updateObservers()); + } + constructor(s) { + ((this.subscriptions = /* @__PURE__ */ new Map()), + (this.updateObservers = () => { + for (const [s, { disposer: t }] of this.subscriptions) s(this.o, t); + }), + void 0 !== s && (this.value = s)); + } + addCallback(s, t, i) { + if (!i) return void s(this.value); + this.subscriptions.has(s) || + this.subscriptions.set(s, { + disposer: () => { + this.subscriptions.delete(s); + }, + consumerHost: t, + }); + const { disposer: h } = this.subscriptions.get(s); + s(this.value, h); + } + clearCallbacks() { + this.subscriptions.clear(); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var e$8 = class extends Event { + constructor(t, s) { + (super("context-provider", { + bubbles: !0, + composed: !0, + }), + (this.context = t), + (this.contextTarget = s)); + } +}; +var i$3 = class extends s$2 { + constructor(s, e, i) { + (super(void 0 !== e.context ? e.initialValue : i), + (this.onContextRequest = (t) => { + if (t.context !== this.context) return; + const s = t.contextTarget ?? t.composedPath()[0]; + s !== this.host && (t.stopPropagation(), this.addCallback(t.callback, s, t.subscribe)); + }), + (this.onProviderRequest = (s) => { + if (s.context !== this.context) return; + if ((s.contextTarget ?? s.composedPath()[0]) === this.host) return; + const e = /* @__PURE__ */ new Set(); + for (const [s, { consumerHost: i }] of this.subscriptions) + e.has(s) || (e.add(s), i.dispatchEvent(new s$4(this.context, i, s, !0))); + s.stopPropagation(); + }), + (this.host = s), + void 0 !== e.context ? (this.context = e.context) : (this.context = e), + this.attachListeners(), + this.host.addController?.(this)); + } + attachListeners() { + (this.host.addEventListener("context-request", this.onContextRequest), + this.host.addEventListener("context-provider", this.onProviderRequest)); + } + hostConnected() { + this.host.dispatchEvent(new e$8(this.context, this.host)); + } +}; +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function c$1({ context: c, subscribe: e }) { + return (o, n) => { + "object" == typeof n + ? n.addInitializer(function () { + new s$3(this, { + context: c, + callback: (t) => { + o.set.call(this, t); + }, + subscribe: e, + }); + }) + : o.constructor.addInitializer((o) => { + new s$3(o, { + context: c, + callback: (t) => { + o[n] = t; + }, + subscribe: e, + }); + }); + }; +} +const eventInit = { + bubbles: true, + cancelable: true, + composed: true, +}; +var StateEvent = class StateEvent extends CustomEvent { + static { + this.eventName = "a2uiaction"; + } + constructor(payload) { + super(StateEvent.eventName, { + detail: payload, + ...eventInit, + }); + this.payload = payload; + } +}; +const opacityBehavior = ` + &:not([disabled]) { + cursor: pointer; + opacity: var(--opacity, 0); + transition: opacity var(--speed, 0.2s) cubic-bezier(0, 0, 0.3, 1); + + &:hover, + &:focus { + opacity: 1; + } + }`; +const behavior = ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.behavior-ho-${idx * 5} { + --opacity: ${idx / 20}; + ${opacityBehavior} + }`; + }) + .join("\n")} + + .behavior-o-s { + overflow: scroll; + } + + .behavior-o-a { + overflow: auto; + } + + .behavior-o-h { + overflow: hidden; + } + + .behavior-sw-n { + scrollbar-width: none; + } +`; +const border = ` + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .border-bw-${idx} { border-width: ${idx}px; } + .border-btw-${idx} { border-top-width: ${idx}px; } + .border-bbw-${idx} { border-bottom-width: ${idx}px; } + .border-blw-${idx} { border-left-width: ${idx}px; } + .border-brw-${idx} { border-right-width: ${idx}px; } + + .border-ow-${idx} { outline-width: ${idx}px; } + .border-br-${idx} { border-radius: ${idx * 4}px; overflow: hidden;}`; + }) + .join("\n")} + + .border-br-50pc { + border-radius: 50%; + } + + .border-bs-s { + border-style: solid; + } +`; +const shades = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; +function merge(...classes) { + const styles = {}; + for (const clazz of classes) + for (const [key, val] of Object.entries(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + const existingKeys = Object.keys(styles).filter((key) => key.startsWith(prefix)); + for (const existingKey of existingKeys) delete styles[existingKey]; + styles[key] = val; + } + return styles; +} +function appendToAll(target, exclusions, ...classes) { + const updatedTarget = structuredClone(target); + for (const clazz of classes) + for (const key of Object.keys(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) { + if (exclusions.includes(tagName)) continue; + let found = false; + for (let t = 0; t < classesToAdd.length; t++) + if (classesToAdd[t].startsWith(prefix)) { + found = true; + classesToAdd[t] = key; + } + if (!found) classesToAdd.push(key); + } + } + return updatedTarget; +} +function toProp(key) { + if (key.startsWith("nv")) return `--nv-${key.slice(2)}`; + return `--${key[0]}-${key.slice(1)}`; +} +const color = (src) => ` + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-bc-${key} { border-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + const vals = [ + `.color-bgc-${key} { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + `.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + ]; + for (let o = 0.1; o < 1; o += 0.1) + vals.push(`.color-bbgc-${key}_${(o * 100).toFixed(0)}::backdrop { + background-color: light-dark(oklch(from var(${toProp(key)}) l c h / calc(alpha * ${o.toFixed(1)})), oklch(from var(${toProp(inverseKey)}) l c h / calc(alpha * ${o.toFixed(1)})) ); + } + `); + return vals.join("\n"); + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-c-${key} { color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + `; +const getInverseKey = (key) => { + const match = key.match(/^([a-z]+)(\d+)$/); + if (!match) return key; + const [, prefix, shadeStr] = match; + const target = 100 - parseInt(shadeStr, 10); + return `${prefix}${shades.reduce((prev, curr) => (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev))}`; +}; +const keyFactory = (prefix) => { + return shades.map((v) => `${prefix}${v}`); +}; +const structuralStyles$1 = [ + behavior, + border, + [ + color(keyFactory("p")), + color(keyFactory("s")), + color(keyFactory("t")), + color(keyFactory("n")), + color(keyFactory("nv")), + color(keyFactory("e")), + ` + .color-bgc-transparent { + background-color: transparent; + } + + :host { + color-scheme: var(--color-scheme); + } + `, + ], + ` + .g-icon { + font-family: "Material Symbols Outlined", "Google Symbols"; + font-weight: normal; + font-style: normal; + font-display: optional; + font-size: 20px; + width: 1em; + height: 1em; + user-select: none; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; + overflow: hidden; + + font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + + &.filled { + font-variation-settings: "FILL" 1, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + + &.filled-heavy { + font-variation-settings: "FILL" 1, "wght" 700, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + } +`, + ` + :host { + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `--g-${idx + 1}: ${(idx + 1) * 4}px;`; + }) + .join("\n")} + } + + ${new Array(49) + .fill(0) + .map((_, index) => { + const idx = index - 24; + const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString(); + return ` + .layout-p-${lbl} { --padding: ${idx * 4}px; padding: var(--padding); } + .layout-pt-${lbl} { padding-top: ${idx * 4}px; } + .layout-pr-${lbl} { padding-right: ${idx * 4}px; } + .layout-pb-${lbl} { padding-bottom: ${idx * 4}px; } + .layout-pl-${lbl} { padding-left: ${idx * 4}px; } + + .layout-m-${lbl} { --margin: ${idx * 4}px; margin: var(--margin); } + .layout-mt-${lbl} { margin-top: ${idx * 4}px; } + .layout-mr-${lbl} { margin-right: ${idx * 4}px; } + .layout-mb-${lbl} { margin-bottom: ${idx * 4}px; } + .layout-ml-${lbl} { margin-left: ${idx * 4}px; } + + .layout-t-${lbl} { top: ${idx * 4}px; } + .layout-r-${lbl} { right: ${idx * 4}px; } + .layout-b-${lbl} { bottom: ${idx * 4}px; } + .layout-l-${lbl} { left: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .layout-g-${idx} { gap: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(8) + .fill(0) + .map((_, idx) => { + return ` + .layout-grd-col${idx + 1} { grid-template-columns: ${"1fr ".repeat(idx + 1).trim()}; }`; + }) + .join("\n")} + + .layout-pos-a { + position: absolute; + } + + .layout-pos-rel { + position: relative; + } + + .layout-dsp-none { + display: none; + } + + .layout-dsp-block { + display: block; + } + + .layout-dsp-grid { + display: grid; + } + + .layout-dsp-iflex { + display: inline-flex; + } + + .layout-dsp-flexvert { + display: flex; + flex-direction: column; + } + + .layout-dsp-flexhor { + display: flex; + flex-direction: row; + } + + .layout-fw-w { + flex-wrap: wrap; + } + + .layout-al-fs { + align-items: start; + } + + .layout-al-fe { + align-items: end; + } + + .layout-al-c { + align-items: center; + } + + .layout-as-n { + align-self: normal; + } + + .layout-js-c { + justify-self: center; + } + + .layout-sp-c { + justify-content: center; + } + + .layout-sp-ev { + justify-content: space-evenly; + } + + .layout-sp-bt { + justify-content: space-between; + } + + .layout-sp-s { + justify-content: start; + } + + .layout-sp-e { + justify-content: end; + } + + .layout-ji-e { + justify-items: end; + } + + .layout-r-none { + resize: none; + } + + .layout-fs-c { + field-sizing: content; + } + + .layout-fs-n { + field-sizing: none; + } + + .layout-flx-0 { + flex: 0 0 auto; + } + + .layout-flx-1 { + flex: 1 0 auto; + } + + .layout-c-s { + contain: strict; + } + + /** Widths **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 10; + return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-wp-${idx} { width: ${idx * 4}px; }`; + }) + .join("\n")} + + /** Heights **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const height = (idx + 1) * 10; + return `.layout-h-${height} { height: ${height}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-hp-${idx} { height: ${idx * 4}px; }`; + }) + .join("\n")} + + .layout-el-cv { + & img, + & video { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + } + } + + .layout-ar-sq { + aspect-ratio: 1 / 1; + } + + .layout-ex-fb { + margin: calc(var(--padding) * -1) 0 0 calc(var(--padding) * -1); + width: calc(100% + var(--padding) * 2); + height: calc(100% + var(--padding) * 2); + } +`, + ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`; + }) + .join("\n")} +`, + ` + :host { + --default-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + --default-font-family-mono: "Courier New", Courier, monospace; + } + + .typography-f-s { + font-family: var(--font-family, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-f-sf { + font-family: var(--font-family-flex, var(--default-font-family)); + font-optical-sizing: auto; + } + + .typography-f-c { + font-family: var(--font-family-mono, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-v-r { + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0, "ROND" 100; + } + + .typography-ta-s { + text-align: start; + } + + .typography-ta-c { + text-align: center; + } + + .typography-fs-n { + font-style: normal; + } + + .typography-fs-i { + font-style: italic; + } + + .typography-sz-ls { + font-size: 11px; + line-height: 16px; + } + + .typography-sz-lm { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-ll { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bs { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-bm { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bl { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-ts { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-tm { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-tl { + font-size: 22px; + line-height: 28px; + } + + .typography-sz-hs { + font-size: 24px; + line-height: 32px; + } + + .typography-sz-hm { + font-size: 28px; + line-height: 36px; + } + + .typography-sz-hl { + font-size: 32px; + line-height: 40px; + } + + .typography-sz-ds { + font-size: 36px; + line-height: 44px; + } + + .typography-sz-dm { + font-size: 45px; + line-height: 52px; + } + + .typography-sz-dl { + font-size: 57px; + line-height: 64px; + } + + .typography-ws-p { + white-space: pre-line; + } + + .typography-ws-nw { + white-space: nowrap; + } + + .typography-td-none { + text-decoration: none; + } + + /** Weights **/ + + ${new Array(9) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 100; + return `.typography-w-${weight} { font-weight: ${weight}; }`; + }) + .join("\n")} +`, +] + .flat(Infinity) + .join("\n"); +var guards_exports = /* @__PURE__ */ __exportAll({ + isComponentArrayReference: () => isComponentArrayReference, + isObject: () => isObject$1, + isPath: () => isPath, + isResolvedAudioPlayer: () => isResolvedAudioPlayer, + isResolvedButton: () => isResolvedButton, + isResolvedCard: () => isResolvedCard, + isResolvedCheckbox: () => isResolvedCheckbox, + isResolvedColumn: () => isResolvedColumn, + isResolvedDateTimeInput: () => isResolvedDateTimeInput, + isResolvedDivider: () => isResolvedDivider, + isResolvedIcon: () => isResolvedIcon, + isResolvedImage: () => isResolvedImage, + isResolvedList: () => isResolvedList, + isResolvedModal: () => isResolvedModal, + isResolvedMultipleChoice: () => isResolvedMultipleChoice, + isResolvedRow: () => isResolvedRow, + isResolvedSlider: () => isResolvedSlider, + isResolvedTabs: () => isResolvedTabs, + isResolvedText: () => isResolvedText, + isResolvedTextField: () => isResolvedTextField, + isResolvedVideo: () => isResolvedVideo, + isValueMap: () => isValueMap, +}); +function isValueMap(value) { + return isObject$1(value) && "key" in value; +} +function isPath(key, value) { + return key === "path" && typeof value === "string"; +} +function isObject$1(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isComponentArrayReference(value) { + if (!isObject$1(value)) return false; + return "explicitList" in value || "template" in value; +} +function isStringValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "string") || + "literalString" in value) + ); +} +function isNumberValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "number") || + "literalNumber" in value) + ); +} +function isBooleanValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "boolean") || + "literalBoolean" in value) + ); +} +function isAnyComponentNode(value) { + if (!isObject$1(value)) return false; + if (!("id" in value && "type" in value && "properties" in value)) return false; + return true; +} +function isResolvedAudioPlayer(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedButton(props) { + return ( + isObject$1(props) && "child" in props && isAnyComponentNode(props.child) && "action" in props + ); +} +function isResolvedCard(props) { + if (!isObject$1(props)) return false; + if (!("child" in props)) + if (!("children" in props)) return false; + else return Array.isArray(props.children) && props.children.every(isAnyComponentNode); + return isAnyComponentNode(props.child); +} +function isResolvedCheckbox(props) { + return ( + isObject$1(props) && + "label" in props && + isStringValue(props.label) && + "value" in props && + isBooleanValue(props.value) + ); +} +function isResolvedColumn(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedDateTimeInput(props) { + return isObject$1(props) && "value" in props && isStringValue(props.value); +} +function isResolvedDivider(props) { + return isObject$1(props); +} +function isResolvedImage(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedIcon(props) { + return isObject$1(props) && "name" in props && isStringValue(props.name); +} +function isResolvedList(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedModal(props) { + return ( + isObject$1(props) && + "entryPointChild" in props && + isAnyComponentNode(props.entryPointChild) && + "contentChild" in props && + isAnyComponentNode(props.contentChild) + ); +} +function isResolvedMultipleChoice(props) { + return isObject$1(props) && "selections" in props; +} +function isResolvedRow(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedSlider(props) { + return isObject$1(props) && "value" in props && isNumberValue(props.value); +} +function isResolvedTabItem(item) { + return ( + isObject$1(item) && + "title" in item && + isStringValue(item.title) && + "child" in item && + isAnyComponentNode(item.child) + ); +} +function isResolvedTabs(props) { + return ( + isObject$1(props) && + "tabItems" in props && + Array.isArray(props.tabItems) && + props.tabItems.every(isResolvedTabItem) + ); +} +function isResolvedText(props) { + return isObject$1(props) && "text" in props && isStringValue(props.text); +} +function isResolvedTextField(props) { + return isObject$1(props) && "label" in props && isStringValue(props.label); +} +function isResolvedVideo(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +/** + * Processes and consolidates A2UIProtocolMessage objects into a structured, + * hierarchical model of UI surfaces. + */ +var A2uiMessageProcessor = class A2uiMessageProcessor { + static { + this.DEFAULT_SURFACE_ID = "@default"; + } + #mapCtor = Map; + #arrayCtor = Array; + #setCtor = Set; + #objCtor = Object; + #surfaces; + constructor( + opts = { + mapCtor: Map, + arrayCtor: Array, + setCtor: Set, + objCtor: Object, + }, + ) { + this.opts = opts; + this.#arrayCtor = opts.arrayCtor; + this.#mapCtor = opts.mapCtor; + this.#setCtor = opts.setCtor; + this.#objCtor = opts.objCtor; + this.#surfaces = new opts.mapCtor(); + } + getSurfaces() { + return this.#surfaces; + } + clearSurfaces() { + this.#surfaces.clear(); + } + processMessages(messages) { + for (const message of messages) { + if (message.beginRendering) + this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId); + if (message.surfaceUpdate) + this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId); + if (message.dataModelUpdate) + this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId); + if (message.deleteSurface) this.#handleDeleteSurface(message.deleteSurface); + } + } + /** + * Retrieves the data for a given component node and a relative path string. + * This correctly handles the special `.` path, which refers to the node's + * own data context. + */ + getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return null; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + return this.#getDataByPath(surface.dataModel, finalPath); + } + setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + if (!node) { + console.warn("No component node set"); + return; + } + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + this.#setDataByPath(surface.dataModel, finalPath, value); + } + resolvePath(path, dataContextPath) { + if (path.startsWith("/")) return path; + if (dataContextPath && dataContextPath !== "/") + return dataContextPath.endsWith("/") + ? `${dataContextPath}${path}` + : `${dataContextPath}/${path}`; + return `/${path}`; + } + #parseIfJsonString(value) { + if (typeof value !== "string") return value; + const trimmedValue = value.trim(); + if ( + (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || + (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) + ) + try { + return JSON.parse(value); + } catch (e) { + console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e); + return value; + } + return value; + } + /** + * Converts a specific array format [{key: "...", value_string: "..."}, ...] + * into a standard Map. It also attempts to parse any string values that + * appear to be stringified JSON. + */ + #convertKeyValueArrayToMap(arr) { + const map = new this.#mapCtor(); + for (const item of arr) { + if (!isObject$1(item) || !("key" in item)) continue; + const key = item.key; + const valueKey = this.#findValueKey(item); + if (!valueKey) continue; + let value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + this.#setDataByPath(map, key, value); + } + return map; + } + #setDataByPath(root, path, value) { + if (Array.isArray(value) && (value.length === 0 || (isObject$1(value[0]) && "key" in value[0]))) + if (value.length === 1 && isObject$1(value[0]) && value[0].key === ".") { + const item = value[0]; + const valueKey = this.#findValueKey(item); + if (valueKey) { + value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + } else value = this.#convertKeyValueArrayToMap(value); + } else value = this.#convertKeyValueArrayToMap(value); + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + if (segments.length === 0) { + if (value instanceof Map || isObject$1(value)) { + if (!(value instanceof Map) && isObject$1(value)) + value = new this.#mapCtor(Object.entries(value)); + root.clear(); + for (const [key, v] of value.entries()) root.set(key, v); + } else console.error("Cannot set root of DataModel to a non-Map value."); + return; + } + let current = root; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let target; + if (current instanceof Map) target = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + target = current[parseInt(segment, 10)]; + if (target === void 0 || typeof target !== "object" || target === null) { + target = new this.#mapCtor(); + if (current instanceof this.#mapCtor) current.set(segment, target); + else if (Array.isArray(current)) current[parseInt(segment, 10)] = target; + } + current = target; + } + const finalSegment = segments[segments.length - 1]; + const storedValue = value; + if (current instanceof this.#mapCtor) current.set(finalSegment, storedValue); + else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) + current[parseInt(finalSegment, 10)] = storedValue; + } + /** + * Normalizes a path string into a consistent, slash-delimited format. + * Converts bracket notation and dot notation in a two-pass. + * e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title" + * e.g., "book.0.title" -> "/book/0/title" + */ + #normalizePath(path) { + return ( + "/" + + path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((s) => s.length > 0) + .join("/") + ); + } + #getDataByPath(root, path) { + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + let current = root; + for (const segment of segments) { + if (current === void 0 || current === null) return null; + if (current instanceof Map) current = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + current = current[parseInt(segment, 10)]; + else if (isObject$1(current)) current = current[segment]; + else return null; + } + return current; + } + #getOrCreateSurface(surfaceId) { + let surface = this.#surfaces.get(surfaceId); + if (!surface) { + surface = new this.#objCtor({ + rootComponentId: null, + componentTree: null, + dataModel: new this.#mapCtor(), + components: new this.#mapCtor(), + styles: new this.#objCtor(), + }); + this.#surfaces.set(surfaceId, surface); + } + return surface; + } + #handleBeginRendering(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + surface.rootComponentId = message.root; + surface.styles = message.styles ?? {}; + this.#rebuildComponentTree(surface); + } + #handleSurfaceUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + for (const component of message.components) surface.components.set(component.id, component); + this.#rebuildComponentTree(surface); + } + #handleDataModelUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + const path = message.path ?? "/"; + this.#setDataByPath(surface.dataModel, path, message.contents); + this.#rebuildComponentTree(surface); + } + #handleDeleteSurface(message) { + this.#surfaces.delete(message.surfaceId); + } + /** + * Starts at the root component of the surface and builds out the tree + * recursively. This process involves resolving all properties of the child + * components, and expanding on any explicit children lists or templates + * found in the structure. + * + * @param surface The surface to be built. + */ + #rebuildComponentTree(surface) { + if (!surface.rootComponentId) { + surface.componentTree = null; + return; + } + const visited = new this.#setCtor(); + surface.componentTree = this.#buildNodeRecursive( + surface.rootComponentId, + surface, + visited, + "/", + "", + ); + } + /** Finds a value key in a map. */ + #findValueKey(value) { + return Object.keys(value).find((k) => k.startsWith("value")); + } + /** + * Builds out the nodes recursively. + */ + #buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") { + const fullId = `${baseComponentId}${idSuffix}`; + const { components } = surface; + if (!components.has(baseComponentId)) return null; + if (visited.has(fullId)) throw new Error(`Circular dependency for component "${fullId}".`); + visited.add(fullId); + const componentData = components.get(baseComponentId); + const componentProps = componentData.component ?? {}; + const componentType = Object.keys(componentProps)[0]; + const unresolvedProperties = componentProps[componentType]; + const resolvedProperties = new this.#objCtor(); + if (isObject$1(unresolvedProperties)) + for (const [key, value] of Object.entries(unresolvedProperties)) + resolvedProperties[key] = this.#resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + visited.delete(fullId); + const baseNode = { + id: fullId, + dataContextPath, + weight: componentData.weight ?? "initial", + }; + switch (componentType) { + case "Text": + if (!isResolvedText(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Text", + properties: resolvedProperties, + }); + case "Image": + if (!isResolvedImage(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Image", + properties: resolvedProperties, + }); + case "Icon": + if (!isResolvedIcon(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Icon", + properties: resolvedProperties, + }); + case "Video": + if (!isResolvedVideo(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Video", + properties: resolvedProperties, + }); + case "AudioPlayer": + if (!isResolvedAudioPlayer(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "AudioPlayer", + properties: resolvedProperties, + }); + case "Row": + if (!isResolvedRow(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Row", + properties: resolvedProperties, + }); + case "Column": + if (!isResolvedColumn(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Column", + properties: resolvedProperties, + }); + case "List": + if (!isResolvedList(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "List", + properties: resolvedProperties, + }); + case "Card": + if (!isResolvedCard(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Card", + properties: resolvedProperties, + }); + case "Tabs": + if (!isResolvedTabs(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Tabs", + properties: resolvedProperties, + }); + case "Divider": + if (!isResolvedDivider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Divider", + properties: resolvedProperties, + }); + case "Modal": + if (!isResolvedModal(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Modal", + properties: resolvedProperties, + }); + case "Button": + if (!isResolvedButton(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Button", + properties: resolvedProperties, + }); + case "CheckBox": + if (!isResolvedCheckbox(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "CheckBox", + properties: resolvedProperties, + }); + case "TextField": + if (!isResolvedTextField(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "TextField", + properties: resolvedProperties, + }); + case "DateTimeInput": + if (!isResolvedDateTimeInput(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "DateTimeInput", + properties: resolvedProperties, + }); + case "MultipleChoice": + if (!isResolvedMultipleChoice(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "MultipleChoice", + properties: resolvedProperties, + }); + case "Slider": + if (!isResolvedSlider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Slider", + properties: resolvedProperties, + }); + default: + return new this.#objCtor({ + ...baseNode, + type: componentType, + properties: resolvedProperties, + }); + } + } + /** + * Recursively resolves an individual property value. If a property indicates + * a child node (a string that matches a component ID), an explicitList of + * children, or a template, these will be built out here. + */ + #resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix = "", + propertyKey = null, + ) { + const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child"); + if ( + typeof value === "string" && + propertyKey && + isComponentIdReferenceKey(propertyKey) && + surface.components.has(value) + ) + return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix); + if (isComponentArrayReference(value)) { + if (value.explicitList) + return value.explicitList.map((id) => + this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix), + ); + if (value.template) { + const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath); + const data = this.#getDataByPath(surface.dataModel, fullDataPath); + const template = value.template; + if (Array.isArray(data)) + return data.map((_, index) => { + const newSuffix = `:${[...dataContextPath.split("/").filter((segment) => /^\d+$/.test(segment)), index].join(":")}`; + const childDataContextPath = `${fullDataPath}/${index}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + if (data instanceof this.#mapCtor) + return Array.from(data.keys(), (key) => { + const newSuffix = `:${key}`; + const childDataContextPath = `${fullDataPath}/${key}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + return new this.#arrayCtor(); + } + } + if (Array.isArray(value)) + return value.map((item) => + this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey), + ); + if (isObject$1(value)) { + const newObj = new this.#objCtor(); + for (const [key, propValue] of Object.entries(value)) { + let propertyValue = propValue; + if (isPath(key, propValue) && dataContextPath !== "/") { + propertyValue = propValue + .replace(/^\.?\/item/, "") + .replace(/^\.?\/text/, "") + .replace(/^\.?\/label/, "") + .replace(/^\.?\//, ""); + newObj[key] = propertyValue; + continue; + } + newObj[key] = this.#resolvePropertyValue( + propertyValue, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + } + return newObj; + } + return value; + } +}; +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value, + }) + : (obj[key] = value); +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; +var __accessCheck = (obj, member, msg) => { + if (!member.has(obj)) throw TypeError("Cannot " + msg); +}; +var __privateIn = (member, obj) => { + if (Object(obj) !== obj) throw TypeError('Cannot use the "in" operator on this value'); + return member.has(obj); +}; +var __privateAdd = (obj, member, value) => { + if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +}; +var __privateMethod = (obj, member, method) => { + __accessCheck(obj, member, "access private method"); + return method; +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultEquals(a, b) { + return Object.is(a, b); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +let activeConsumer = null; +let inNotificationPhase = false; +let epoch = 1; +const SIGNAL = /* @__PURE__ */ Symbol("SIGNAL"); +function setActiveConsumer(consumer) { + const prev = activeConsumer; + activeConsumer = consumer; + return prev; +} +function getActiveConsumer() { + return activeConsumer; +} +function isInNotificationPhase() { + return inNotificationPhase; +} +const REACTIVE_NODE = { + version: 0, + lastCleanEpoch: 0, + dirty: false, + producerNode: void 0, + producerLastReadVersion: void 0, + producerIndexOfThis: void 0, + nextProducerIndex: 0, + liveConsumerNode: void 0, + liveConsumerIndexOfThis: void 0, + consumerAllowSignalWrites: false, + consumerIsAlwaysLive: false, + producerMustRecompute: () => false, + producerRecomputeValue: () => {}, + consumerMarkedDirty: () => {}, + consumerOnSignalRead: () => {}, +}; +function producerAccessed(node) { + if (inNotificationPhase) + throw new Error( + typeof ngDevMode !== "undefined" && ngDevMode + ? `Assertion error: signal read during notification phase` + : "", + ); + if (activeConsumer === null) return; + activeConsumer.consumerOnSignalRead(node); + const idx = activeConsumer.nextProducerIndex++; + assertConsumerNode(activeConsumer); + if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { + if (consumerIsLive(activeConsumer)) { + const staleProducer = activeConsumer.producerNode[idx]; + producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); + } + } + if (activeConsumer.producerNode[idx] !== node) { + activeConsumer.producerNode[idx] = node; + activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) + ? producerAddLiveConsumer(node, activeConsumer, idx) + : 0; + } + activeConsumer.producerLastReadVersion[idx] = node.version; +} +function producerIncrementEpoch() { + epoch++; +} +function producerUpdateValueVersion(node) { + if (!node.dirty && node.lastCleanEpoch === epoch) return; + if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { + node.dirty = false; + node.lastCleanEpoch = epoch; + return; + } + node.producerRecomputeValue(node); + node.dirty = false; + node.lastCleanEpoch = epoch; +} +function producerNotifyConsumers(node) { + if (node.liveConsumerNode === void 0) return; + const prev = inNotificationPhase; + inNotificationPhase = true; + try { + for (const consumer of node.liveConsumerNode) if (!consumer.dirty) consumerMarkDirty(consumer); + } finally { + inNotificationPhase = prev; + } +} +function producerUpdatesAllowed() { + return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false; +} +function consumerMarkDirty(node) { + var _a; + node.dirty = true; + producerNotifyConsumers(node); + (_a = node.consumerMarkedDirty) == null || _a.call(node.wrapper ?? node); +} +function consumerBeforeComputation(node) { + node && (node.nextProducerIndex = 0); + return setActiveConsumer(node); +} +function consumerAfterComputation(node, prevConsumer) { + setActiveConsumer(prevConsumer); + if ( + !node || + node.producerNode === void 0 || + node.producerIndexOfThis === void 0 || + node.producerLastReadVersion === void 0 + ) + return; + if (consumerIsLive(node)) + for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + while (node.producerNode.length > node.nextProducerIndex) { + node.producerNode.pop(); + node.producerLastReadVersion.pop(); + node.producerIndexOfThis.pop(); + } +} +function consumerPollProducersForChange(node) { + assertConsumerNode(node); + for (let i = 0; i < node.producerNode.length; i++) { + const producer = node.producerNode[i]; + const seenVersion = node.producerLastReadVersion[i]; + if (seenVersion !== producer.version) return true; + producerUpdateValueVersion(producer); + if (seenVersion !== producer.version) return true; + } + return false; +} +function producerAddLiveConsumer(node, consumer, indexOfThis) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (node.liveConsumerNode.length === 0) { + (_a = node.watched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); + } + node.liveConsumerIndexOfThis.push(indexOfThis); + return node.liveConsumerNode.push(consumer) - 1; +} +function producerRemoveLiveConsumerAtIndex(node, idx) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) + throw new Error( + `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, + ); + if (node.liveConsumerNode.length === 1) { + (_a = node.unwatched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + } + const lastIdx = node.liveConsumerNode.length - 1; + node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; + node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; + node.liveConsumerNode.length--; + node.liveConsumerIndexOfThis.length--; + if (idx < node.liveConsumerNode.length) { + const idxProducer = node.liveConsumerIndexOfThis[idx]; + const consumer = node.liveConsumerNode[idx]; + assertConsumerNode(consumer); + consumer.producerIndexOfThis[idxProducer] = idx; + } +} +function consumerIsLive(node) { + var _a; + return ( + node.consumerIsAlwaysLive || + (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0 + ); +} +function assertConsumerNode(node) { + node.producerNode ?? (node.producerNode = []); + node.producerIndexOfThis ?? (node.producerIndexOfThis = []); + node.producerLastReadVersion ?? (node.producerLastReadVersion = []); +} +function assertProducerNode(node) { + node.liveConsumerNode ?? (node.liveConsumerNode = []); + node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function computedGet(node) { + producerUpdateValueVersion(node); + producerAccessed(node); + if (node.value === ERRORED) throw node.error; + return node.value; +} +function createComputed(computation) { + const node = Object.create(COMPUTED_NODE); + node.computation = computation; + const computed = () => computedGet(node); + computed[SIGNAL] = node; + return computed; +} +const UNSET = /* @__PURE__ */ Symbol("UNSET"); +const COMPUTING = /* @__PURE__ */ Symbol("COMPUTING"); +const ERRORED = /* @__PURE__ */ Symbol("ERRORED"); +const COMPUTED_NODE = { + ...REACTIVE_NODE, + value: UNSET, + dirty: true, + error: null, + equal: defaultEquals, + producerMustRecompute(node) { + return node.value === UNSET || node.value === COMPUTING; + }, + producerRecomputeValue(node) { + if (node.value === COMPUTING) throw new Error("Detected cycle in computations."); + const oldValue = node.value; + node.value = COMPUTING; + const prevConsumer = consumerBeforeComputation(node); + let newValue; + let wasEqual = false; + try { + newValue = node.computation.call(node.wrapper); + wasEqual = + oldValue !== UNSET && + oldValue !== ERRORED && + node.equal.call(node.wrapper, oldValue, newValue); + } catch (err) { + newValue = ERRORED; + node.error = err; + } finally { + consumerAfterComputation(node, prevConsumer); + } + if (wasEqual) { + node.value = oldValue; + return; + } + node.value = newValue; + node.version++; + }, +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultThrowError() { + throw new Error(); +} +let throwInvalidWriteToSignalErrorFn = defaultThrowError; +function throwInvalidWriteToSignalError() { + throwInvalidWriteToSignalErrorFn(); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function createSignal(initialValue) { + const node = Object.create(SIGNAL_NODE); + node.value = initialValue; + const getter = () => { + producerAccessed(node); + return node.value; + }; + getter[SIGNAL] = node; + return getter; +} +function signalGetFn() { + producerAccessed(this); + return this.value; +} +function signalSetFn(node, newValue) { + if (!producerUpdatesAllowed()) throwInvalidWriteToSignalError(); + if (!node.equal.call(node.wrapper, node.value, newValue)) { + node.value = newValue; + signalValueChanged(node); + } +} +const SIGNAL_NODE = { + ...REACTIVE_NODE, + equal: defaultEquals, + value: void 0, +}; +function signalValueChanged(node) { + node.version++; + producerIncrementEpoch(); + producerNotifyConsumers(node); +} +/** + * @license + * Copyright 2024 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const NODE = Symbol("node"); +var Signal; +((Signal2) => { + var _a, _brand, _b, _brand2; + class State { + constructor(initialValue, options = {}) { + __privateAdd(this, _brand); + __publicField(this, _a); + const node = createSignal(initialValue)[SIGNAL]; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); + return signalGetFn.call(this[NODE]); + } + set(newValue) { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); + if (isInNotificationPhase()) + throw new Error("Writes to signals not permitted during Watcher callback"); + const ref = this[NODE]; + signalSetFn(ref, newValue); + } + } + _a = NODE; + _brand = /* @__PURE__ */ new WeakSet(); + Signal2.isState = (s) => typeof s === "object" && __privateIn(_brand, s); + Signal2.State = State; + class Computed { + constructor(computation, options) { + __privateAdd(this, _brand2); + __publicField(this, _b); + const node = createComputed(computation)[SIGNAL]; + node.consumerAllowSignalWrites = true; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isComputed)(this)) + throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); + return computedGet(this[NODE]); + } + } + _b = NODE; + _brand2 = /* @__PURE__ */ new WeakSet(); + Signal2.isComputed = (c) => typeof c === "object" && __privateIn(_brand2, c); + Signal2.Computed = Computed; + ((subtle2) => { + var _a2, _brand3, _assertSignals, assertSignals_fn; + function untrack(cb) { + let output; + let prevActiveConsumer = null; + try { + prevActiveConsumer = setActiveConsumer(null); + output = cb(); + } finally { + setActiveConsumer(prevActiveConsumer); + } + return output; + } + subtle2.untrack = untrack; + function introspectSources(sink) { + var _a3; + if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) + throw new TypeError("Called introspectSources without a Computed or Watcher argument"); + return ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; + } + subtle2.introspectSources = introspectSources; + function introspectSinks(signal) { + var _a3; + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called introspectSinks without a Signal argument"); + return ( + ((_a3 = signal[NODE].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? [] + ); + } + subtle2.introspectSinks = introspectSinks; + function hasSinks(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called hasSinks without a Signal argument"); + const liveConsumerNode = signal[NODE].liveConsumerNode; + if (!liveConsumerNode) return false; + return liveConsumerNode.length > 0; + } + subtle2.hasSinks = hasSinks; + function hasSources(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) + throw new TypeError("Called hasSources without a Computed or Watcher argument"); + const producerNode = signal[NODE].producerNode; + if (!producerNode) return false; + return producerNode.length > 0; + } + subtle2.hasSources = hasSources; + class Watcher { + constructor(notify) { + __privateAdd(this, _brand3); + __privateAdd(this, _assertSignals); + __publicField(this, _a2); + let node = Object.create(REACTIVE_NODE); + node.wrapper = this; + node.consumerMarkedDirty = notify; + node.consumerIsAlwaysLive = true; + node.consumerAllowSignalWrites = false; + node.producerNode = []; + this[NODE] = node; + } + watch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + node.dirty = false; + const prev = setActiveConsumer(node); + for (const signal of signals) producerAccessed(signal[NODE]); + setActiveConsumer(prev); + } + unwatch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + assertConsumerNode(node); + for (let i = node.producerNode.length - 1; i >= 0; i--) + if (signals.includes(node.producerNode[i].wrapper)) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + const lastIdx = node.producerNode.length - 1; + node.producerNode[i] = node.producerNode[lastIdx]; + node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; + node.producerNode.length--; + node.producerIndexOfThis.length--; + node.nextProducerIndex--; + if (i < node.producerNode.length) { + const idxConsumer = node.producerIndexOfThis[i]; + const producer = node.producerNode[i]; + assertProducerNode(producer); + producer.liveConsumerIndexOfThis[idxConsumer] = i; + } + } + } + getPending() { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called getPending without Watcher receiver"); + return this[NODE].producerNode.filter((n) => n.dirty).map((n) => n.wrapper); + } + } + _a2 = NODE; + _brand3 = /* @__PURE__ */ new WeakSet(); + _assertSignals = /* @__PURE__ */ new WeakSet(); + assertSignals_fn = function (signals) { + for (const signal of signals) + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called watch/unwatch without a Computed or State argument"); + }; + Signal2.isWatcher = (w) => __privateIn(_brand3, w); + subtle2.Watcher = Watcher; + function currentComputed() { + var _a3; + return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper; + } + subtle2.currentComputed = currentComputed; + subtle2.watched = Symbol("watched"); + subtle2.unwatched = Symbol("unwatched"); + })(Signal2.subtle || (Signal2.subtle = {})); +})(Signal || (Signal = {})); +/** + * equality check here is always false so that we can dirty the storage + * via setting to _anything_ + * + * + * This is for a pattern where we don't *directly* use signals to back the values used in collections + * so that instanceof checks and getters and other native features "just work" without having + * to do nested proxying. + * + * (though, see deep.ts for nested / deep behavior) + */ +const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); +const ARRAY_GETTER_METHODS = new Set([ + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values", +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set(["fill", "push", "unshift"]); +function convertToInt(prop) { + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; +} +var SignalArray = class SignalArray { + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn + ? new SignalArray(Array.from(iterable, mapfn, thisArg)) + : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = /* @__PURE__ */ new Map(); + /** + Flag to track whether we have *just* intercepted a call to `.push()` or + `.unshift()`, since in those cases (and only those cases!) the `Array` + itself checks `.length` to return from the function call. + */ + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) + nativelyAccessingLengthFromPushOrUnshift = false; + else self.#collection.get(); + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) + nativelyAccessingLengthFromPushOrUnshift = true; + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === void 0) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") self.#collection.set(null); + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + }, + }); + } + #collection = createStorage(); + #storages = /* @__PURE__ */ new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) storage.set(null); + } +}; +Object.setPrototypeOf(SignalArray.prototype, Array.prototype); +var SignalMap = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalMap.prototype, Map.prototype); +/** + * Create a reactive Object, backed by Signals, using a Proxy. + * This allows dynamic creation and deletion of signals using the object primitive + * APIs that most folks are familiar with -- the only difference is instantiation. + * ```js + * const obj = new SignalObject({ foo: 123 }); + * + * obj.foo // 123 + * obj.foo = 456 + * obj.foo // 456 + * obj.bar = 2 + * obj.bar // 2 + * ``` + */ +const SignalObject = class SignalObjectImpl { + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = /* @__PURE__ */ new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + }, + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) storage.set(null); + } + #dirtyCollection() { + this.#collection.set(null); + } +}; +var SignalSet = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalSet.prototype, Set.prototype); +function create() { + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet, + }); +} +const Data = { + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports, +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$1 = (t) => (e, o) => { + void 0 !== o + ? o.addInitializer(() => { + customElements.define(t, e); + }) + : customElements.define(t, e); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3, + }, + r$7 = (t = o$9, e, r) => { + const { kind: n, metadata: i } = r; + let s = globalThis.litPropertyMetadata.get(i); + if ( + (void 0 === s && globalThis.litPropertyMetadata.set(i, (s = /* @__PURE__ */ new Map())), + "setter" === n && ((t = Object.create(t)).wrapped = !0), + s.set(r.name, t), + "accessor" === n) + ) { + const { name: o } = r; + return { + set(r) { + const n = e.get.call(this); + (e.set.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }, + init(e) { + return (void 0 !== e && this.C(o, void 0, t, e), e); + }, + }; + } + if ("setter" === n) { + const { name: o } = r; + return function (r) { + const n = this[o]; + (e.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }; + } + throw Error("Unsupported decorator location: " + n); + }; +function n$6(t) { + return (e, o) => + "object" == typeof o + ? r$7(t, e, o) + : ((t, e, o) => { + const r = e.hasOwnProperty(o); + return ( + e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0 + ); + })(t, e, o); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function r$6(r) { + return n$6({ + ...r, + state: !0, + attribute: !1, + }); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const e$6 = (e, t, c) => ( + (c.configurable = !0), + (c.enumerable = !0), + Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), + c +); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function e$5(e, r) { + return (n, s, i) => { + const o = (t) => t.renderRoot?.querySelector(e) ?? null; + if (r) { + const { get: e, set: r } = + "object" == typeof s + ? n + : (i ?? + (() => { + const t = Symbol(); + return { + get() { + return this[t]; + }, + set(e) { + this[t] = e; + }, + }; + })()); + return e$6(n, s, { + get() { + let t = e.call(this); + return ( + void 0 === t && ((t = o(this)), (null !== t || this.hasUpdated) && r.call(this, t)), t + ); + }, + }); + } + return e$6(n, s, { + get() { + return o(this); + }, + }); + }; +} +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ let i$2 = !1; +const s$1 = new Signal.subtle.Watcher(() => { + i$2 || + ((i$2 = !0), + queueMicrotask(() => { + i$2 = !1; + for (const t of s$1.getPending()) t.get(); + s$1.watch(); + })); + }), + h$3 = Symbol("SignalWatcherBrand"), + e$3 = new FinalizationRegistry((i) => { + i.unwatch(...Signal.subtle.introspectSources(i)); + }), + n$4 = /* @__PURE__ */ new WeakMap(); +function o$7(i) { + return !0 === i[h$3] + ? (console.warn("SignalWatcher should not be applied to the same class more than once."), i) + : class extends i { + constructor() { + (super(...arguments), + (this._$St = /* @__PURE__ */ new Map()), + (this._$So = new Signal.State(0)), + (this._$Si = !1)); + } + _$Sl() { + var t, i; + const s = [], + h = []; + this._$St.forEach((t, i) => { + ((null == t ? void 0 : t.beforeUpdate) ? s : h).push(i); + }); + const e = + null === (t = this.h) || void 0 === t + ? void 0 + : t.getPending().filter((t) => t !== this._$Su && !this._$St.has(t)); + (s.forEach((t) => t.get()), + null === (i = this._$Su) || void 0 === i || i.get(), + e.forEach((t) => t.get()), + h.forEach((t) => t.get())); + } + _$Sv() { + this.isUpdatePending || + queueMicrotask(() => { + this.isUpdatePending || this._$Sl(); + }); + } + _$S_() { + if (void 0 !== this.h) return; + this._$Su = new Signal.Computed(() => { + (this._$So.get(), super.performUpdate()); + }); + const i = (this.h = new Signal.subtle.Watcher(function () { + const t = n$4.get(this); + void 0 !== t && + (!1 === t._$Si && + (new Set(this.getPending()).has(t._$Su) ? t.requestUpdate() : t._$Sv()), + this.watch()); + })); + (n$4.set(i, this), + e$3.register(this, i), + i.watch(this._$Su), + i.watch(...Array.from(this._$St).map(([t]) => t))); + } + _$Sp() { + if (void 0 === this.h) return; + let i = !1; + (this.h.unwatch( + ...Signal.subtle.introspectSources(this.h).filter((t) => { + var s; + const h = + !0 !== (null === (s = this._$St.get(t)) || void 0 === s ? void 0 : s.manualDispose); + return (h && this._$St.delete(t), i || (i = !h), h); + }), + ), + i || ((this._$Su = void 0), (this.h = void 0), this._$St.clear())); + } + updateEffect(i, s) { + var h; + this._$S_(); + const e = new Signal.Computed(() => { + i(); + }); + return ( + this.h.watch(e), + this._$St.set(e, s), + null !== (h = null == s ? void 0 : s.beforeUpdate) && void 0 !== h && h + ? Signal.subtle.untrack(() => e.get()) + : this.updateComplete.then(() => Signal.subtle.untrack(() => e.get())), + () => { + (this._$St.delete(e), this.h.unwatch(e), !1 === this.isConnected && this._$Sp()); + } + ); + } + performUpdate() { + this.isUpdatePending && + (this._$S_(), + (this._$Si = !0), + this._$So.set(this._$So.get() + 1), + (this._$Si = !1), + this._$Sl()); + } + connectedCallback() { + (super.connectedCallback(), this.requestUpdate()); + } + disconnectedCallback() { + (super.disconnectedCallback(), + queueMicrotask(() => { + !1 === this.isConnected && this._$Sp(); + })); + } + }; +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s = (i, t) => { + const e = i._$AN; + if (void 0 === e) return !1; + for (const i of e) (i._$AO?.(t, !1), s(i, t)); + return !0; + }, + o$6 = (i) => { + let t, e; + do { + if (void 0 === (t = i._$AM)) break; + ((e = t._$AN), e.delete(i), (i = t)); + } while (0 === e?.size); + }, + r$3 = (i) => { + for (let t; (t = i._$AM); i = t) { + let e = t._$AN; + if (void 0 === e) t._$AN = e = /* @__PURE__ */ new Set(); + else if (e.has(i)) break; + (e.add(i), c(t)); + } + }; +function h$2(i) { + void 0 !== this._$AN ? (o$6(this), (this._$AM = i), r$3(this)) : (this._$AM = i); +} +function n$3(i, t = !1, e = 0) { + const r = this._$AH, + h = this._$AN; + if (void 0 !== h && 0 !== h.size) + if (t) + if (Array.isArray(r)) for (let i = e; i < r.length; i++) (s(r[i], !1), o$6(r[i])); + else null != r && (s(r, !1), o$6(r)); + else s(this, i); +} +const c = (i) => { + i.type == t$4.CHILD && ((i._$AP ??= n$3), (i._$AQ ??= h$2)); +}; +var f = class extends i$5 { + constructor() { + (super(...arguments), (this._$AN = void 0)); + } + _$AT(i, t, e) { + (super._$AT(i, t, e), r$3(this), (this.isConnected = i._$AU)); + } + _$AO(i, t = !0) { + (i !== this.isConnected && + ((this.isConnected = i), i ? this.reconnected?.() : this.disconnected?.()), + t && (s(this, i), o$6(this))); + } + setValue(t) { + if (r$8(this._$Ct)) this._$Ct._$AI(t, this); + else { + const i = [...this._$Ct._$AH]; + ((i[this._$Ci] = t), this._$Ct._$AI(i, this, 0)); + } + } + disconnected() {} + reconnected() {} +}; +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +let o$5 = !1; +const n$2 = new Signal.subtle.Watcher(async () => { + o$5 || + ((o$5 = !0), + queueMicrotask(() => { + o$5 = !1; + for (const i of n$2.getPending()) i.get(); + n$2.watch(); + })); +}); +var r$2 = class extends f { + _$S_() { + var i, t; + void 0 === this._$Sm && + ((this._$Sj = new Signal.Computed(() => { + var i; + const t = null === (i = this._$SW) || void 0 === i ? void 0 : i.get(); + return (this.setValue(t), t); + })), + (this._$Sm = + null !== (t = null === (i = this._$Sk) || void 0 === i ? void 0 : i.h) && void 0 !== t + ? t + : n$2), + this._$Sm.watch(this._$Sj), + Signal.subtle.untrack(() => { + var i; + return null === (i = this._$Sj) || void 0 === i ? void 0 : i.get(); + })); + } + _$Sp() { + void 0 !== this._$Sm && (this._$Sm.unwatch(this._$SW), (this._$Sm = void 0)); + } + render(i) { + return Signal.subtle.untrack(() => i.get()); + } + update(i, [t]) { + var o, n; + return ( + (null !== (o = this._$Sk) && void 0 !== o) || + (this._$Sk = null === (n = i.options) || void 0 === n ? void 0 : n.host), + t !== this._$SW && void 0 !== this._$SW && this._$Sp(), + (this._$SW = t), + this._$S_(), + Signal.subtle.untrack(() => this._$SW.get()) + ); + } + disconnected() { + this._$Sp(); + } + reconnected() { + this._$S_(); + } +}; +const h$1 = e$10(r$2), + m = + (o) => + (t, ...m) => + o( + t, + ...m.map((o) => (o instanceof Signal.State || o instanceof Signal.Computed ? h$1(o) : o)), + ); +m(b); +m(w); +Signal.State; +Signal.Computed; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* o$3(o, f) { + if (void 0 !== o) { + let i = 0; + for (const t of o) yield f(t, i++); + } +} +let pending = false; +let watcher = new Signal.subtle.Watcher(() => { + if (!pending) { + pending = true; + queueMicrotask(() => { + pending = false; + flushPending(); + }); + } +}); +function flushPending() { + for (const signal of watcher.getPending()) signal.get(); + watcher.watch(); +} +/** + * ⚠️ WARNING: Nothing unwatches ⚠️ + * This will produce a memory leak. + */ +function effect(cb) { + let c = new Signal.Computed(() => cb()); + watcher.watch(c); + c.get(); + return () => { + watcher.unwatch(c); + }; +} +const themeContext = n$7("A2UITheme"); +const structuralStyles = r$11(structuralStyles$1); +var ComponentRegistry = class { + constructor() { + this.registry = /* @__PURE__ */ new Map(); + } + register(typeName, constructor, tagName) { + if (!/^[a-zA-Z0-9]+$/.test(typeName)) + throw new Error(`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`); + this.registry.set(typeName, constructor); + const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`; + const existingName = customElements.getName(constructor); + if (existingName) { + if (existingName !== actualTagName) + throw new Error( + `Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`, + ); + return; + } + if (!customElements.get(actualTagName)) customElements.define(actualTagName, constructor); + } + get(typeName) { + return this.registry.get(typeName); + } +}; +const componentRegistry = new ComponentRegistry(); +var __runInitializers$19 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +var __esDecorate$19 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +let Root = (() => { + let _classDecorators = [t$1("a2ui-root")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = o$7(i$6); + let _instanceExtraInitializers = []; + let _surfaceId_decorators; + let _surfaceId_initializers = []; + let _surfaceId_extraInitializers = []; + let _component_decorators; + let _component_initializers = []; + let _component_extraInitializers = []; + let _theme_decorators; + let _theme_initializers = []; + let _theme_extraInitializers = []; + let _childComponents_decorators; + let _childComponents_initializers = []; + let _childComponents_extraInitializers = []; + let _processor_decorators; + let _processor_initializers = []; + let _processor_extraInitializers = []; + let _dataContextPath_decorators; + let _dataContextPath_initializers = []; + let _dataContextPath_extraInitializers = []; + let _enableCustomElements_decorators; + let _enableCustomElements_initializers = []; + let _enableCustomElements_extraInitializers = []; + let _set_weight_decorators; + var Root = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _surfaceId_decorators = [n$6()]; + _component_decorators = [n$6()]; + _theme_decorators = [c$1({ context: themeContext })]; + _childComponents_decorators = [n$6({ attribute: false })]; + _processor_decorators = [n$6({ attribute: false })]; + _dataContextPath_decorators = [n$6()]; + _enableCustomElements_decorators = [n$6()]; + _set_weight_decorators = [n$6()]; + __esDecorate$19( + this, + null, + _surfaceId_decorators, + { + kind: "accessor", + name: "surfaceId", + static: false, + private: false, + access: { + has: (obj) => "surfaceId" in obj, + get: (obj) => obj.surfaceId, + set: (obj, value) => { + obj.surfaceId = value; + }, + }, + metadata: _metadata, + }, + _surfaceId_initializers, + _surfaceId_extraInitializers, + ); + __esDecorate$19( + this, + null, + _component_decorators, + { + kind: "accessor", + name: "component", + static: false, + private: false, + access: { + has: (obj) => "component" in obj, + get: (obj) => obj.component, + set: (obj, value) => { + obj.component = value; + }, + }, + metadata: _metadata, + }, + _component_initializers, + _component_extraInitializers, + ); + __esDecorate$19( + this, + null, + _theme_decorators, + { + kind: "accessor", + name: "theme", + static: false, + private: false, + access: { + has: (obj) => "theme" in obj, + get: (obj) => obj.theme, + set: (obj, value) => { + obj.theme = value; + }, + }, + metadata: _metadata, + }, + _theme_initializers, + _theme_extraInitializers, + ); + __esDecorate$19( + this, + null, + _childComponents_decorators, + { + kind: "accessor", + name: "childComponents", + static: false, + private: false, + access: { + has: (obj) => "childComponents" in obj, + get: (obj) => obj.childComponents, + set: (obj, value) => { + obj.childComponents = value; + }, + }, + metadata: _metadata, + }, + _childComponents_initializers, + _childComponents_extraInitializers, + ); + __esDecorate$19( + this, + null, + _processor_decorators, + { + kind: "accessor", + name: "processor", + static: false, + private: false, + access: { + has: (obj) => "processor" in obj, + get: (obj) => obj.processor, + set: (obj, value) => { + obj.processor = value; + }, + }, + metadata: _metadata, + }, + _processor_initializers, + _processor_extraInitializers, + ); + __esDecorate$19( + this, + null, + _dataContextPath_decorators, + { + kind: "accessor", + name: "dataContextPath", + static: false, + private: false, + access: { + has: (obj) => "dataContextPath" in obj, + get: (obj) => obj.dataContextPath, + set: (obj, value) => { + obj.dataContextPath = value; + }, + }, + metadata: _metadata, + }, + _dataContextPath_initializers, + _dataContextPath_extraInitializers, + ); + __esDecorate$19( + this, + null, + _enableCustomElements_decorators, + { + kind: "accessor", + name: "enableCustomElements", + static: false, + private: false, + access: { + has: (obj) => "enableCustomElements" in obj, + get: (obj) => obj.enableCustomElements, + set: (obj, value) => { + obj.enableCustomElements = value; + }, + }, + metadata: _metadata, + }, + _enableCustomElements_initializers, + _enableCustomElements_extraInitializers, + ); + __esDecorate$19( + this, + null, + _set_weight_decorators, + { + kind: "setter", + name: "weight", + static: false, + private: false, + access: { + has: (obj) => "weight" in obj, + set: (obj, value) => { + obj.weight = value; + }, + }, + metadata: _metadata, + }, + null, + _instanceExtraInitializers, + ); + __esDecorate$19( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Root = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #surfaceId_accessor_storage = + (__runInitializers$19(this, _instanceExtraInitializers), + __runInitializers$19(this, _surfaceId_initializers, null)); + get surfaceId() { + return this.#surfaceId_accessor_storage; + } + set surfaceId(value) { + this.#surfaceId_accessor_storage = value; + } + #component_accessor_storage = + (__runInitializers$19(this, _surfaceId_extraInitializers), + __runInitializers$19(this, _component_initializers, null)); + get component() { + return this.#component_accessor_storage; + } + set component(value) { + this.#component_accessor_storage = value; + } + #theme_accessor_storage = + (__runInitializers$19(this, _component_extraInitializers), + __runInitializers$19(this, _theme_initializers, void 0)); + get theme() { + return this.#theme_accessor_storage; + } + set theme(value) { + this.#theme_accessor_storage = value; + } + #childComponents_accessor_storage = + (__runInitializers$19(this, _theme_extraInitializers), + __runInitializers$19(this, _childComponents_initializers, null)); + get childComponents() { + return this.#childComponents_accessor_storage; + } + set childComponents(value) { + this.#childComponents_accessor_storage = value; + } + #processor_accessor_storage = + (__runInitializers$19(this, _childComponents_extraInitializers), + __runInitializers$19(this, _processor_initializers, null)); + get processor() { + return this.#processor_accessor_storage; + } + set processor(value) { + this.#processor_accessor_storage = value; + } + #dataContextPath_accessor_storage = + (__runInitializers$19(this, _processor_extraInitializers), + __runInitializers$19(this, _dataContextPath_initializers, "")); + get dataContextPath() { + return this.#dataContextPath_accessor_storage; + } + set dataContextPath(value) { + this.#dataContextPath_accessor_storage = value; + } + #enableCustomElements_accessor_storage = + (__runInitializers$19(this, _dataContextPath_extraInitializers), + __runInitializers$19(this, _enableCustomElements_initializers, false)); + get enableCustomElements() { + return this.#enableCustomElements_accessor_storage; + } + set enableCustomElements(value) { + this.#enableCustomElements_accessor_storage = value; + } + set weight(weight) { + this.#weight = weight; + this.style.setProperty("--weight", `${weight}`); + } + get weight() { + return this.#weight; + } + #weight = (__runInitializers$19(this, _enableCustomElements_extraInitializers), 1); + static { + this.styles = [ + structuralStyles, + i$9` + :host { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 80%; + } + `, + ]; + } + /** + * Holds the cleanup function for our effect. + * We need this to stop the effect when the component is disconnected. + */ + #lightDomEffectDisposer = null; + willUpdate(changedProperties) { + if (changedProperties.has("childComponents")) { + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + this.#lightDomEffectDisposer = effect(() => { + const allChildren = this.childComponents ?? null; + D(this.renderComponentTree(allChildren), this, { host: this }); + }); + } + } + /** + * Clean up the effect when the component is removed from the DOM. + */ + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + } + /** + * Turns the SignalMap into a renderable TemplateResult for Lit. + */ + renderComponentTree(components) { + if (!components) return A; + if (!Array.isArray(components)) return A; + return b` ${o$3(components, (component) => { + if (this.enableCustomElements) { + const elCtor = + componentRegistry.get(component.type) || customElements.get(component.type); + if (elCtor) { + const node = component; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + } + switch (component.type) { + case "List": { + const node = component; + const childComponents = node.properties.children; + return b``; + } + case "Card": { + const node = component; + let childComponents = node.properties.children; + if (!childComponents && node.properties.child) + childComponents = [node.properties.child]; + return b``; + } + case "Column": { + const node = component; + return b``; + } + case "Row": { + const node = component; + return b``; + } + case "Image": { + const node = component; + return b``; + } + case "Icon": { + const node = component; + return b``; + } + case "AudioPlayer": { + const node = component; + return b``; + } + case "Button": { + const node = component; + return b``; + } + case "Text": { + const node = component; + return b``; + } + case "CheckBox": { + const node = component; + return b``; + } + case "DateTimeInput": { + const node = component; + return b``; + } + case "Divider": { + const node = component; + return b``; + } + case "MultipleChoice": { + const node = component; + return b``; + } + case "Slider": { + const node = component; + return b``; + } + case "TextField": { + const node = component; + return b``; + } + case "Video": { + const node = component; + return b``; + } + case "Tabs": { + const node = component; + const titles = []; + const childComponents = []; + if (node.properties.tabItems) + for (const item of node.properties.tabItems) { + titles.push(item.title); + childComponents.push(item.child); + } + return b``; + } + case "Modal": { + const node = component; + const childComponents = [node.properties.entryPointChild, node.properties.contentChild]; + node.properties.entryPointChild.slotName = "entry"; + return b``; + } + default: + return this.renderCustomComponent(component); + } + })}`; + } + renderCustomComponent(component) { + if (!this.enableCustomElements) return; + const node = component; + const elCtor = componentRegistry.get(component.type) || customElements.get(component.type); + if (!elCtor) return b`Unknown element ${component.type}`; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + render() { + return b``; + } + static { + __runInitializers$19(_classThis, _classExtraInitializers); + } + }; + return _classThis; +})(); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const e$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "class" !== t.name || t.strings?.length > 2)) + throw Error( + "`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return ( + " " + + Object.keys(t) + .filter((s) => t[s]) + .join(" ") + + " " + ); + } + update(s, [i]) { + if (void 0 === this.st) { + ((this.st = /* @__PURE__ */ new Set()), + void 0 !== s.strings && + (this.nt = new Set( + s.strings + .join(" ") + .split(/\s/) + .filter((t) => "" !== t), + ))); + for (const t in i) i[t] && !this.nt?.has(t) && this.st.add(t); + return this.render(i); + } + const r = s.element.classList; + for (const t of this.st) t in i || (r.remove(t), this.st.delete(t)); + for (const t in i) { + const s = !!i[t]; + s === this.st.has(t) || + this.nt?.has(t) || + (s ? (r.add(t), this.st.add(t)) : (r.remove(t), this.st.delete(t))); + } + return E; + } + }, +); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const n$1 = "important", + i = " !" + n$1, + o$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "style" !== t.name || t.strings?.length > 2)) + throw Error( + "The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return Object.keys(t).reduce((e, r) => { + const s = t[r]; + return null == s + ? e + : e + + `${(r = r.includes("-") ? r : r.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, "-$&").toLowerCase())}:${s};`; + }, ""); + } + update(e, [r]) { + const { style: s } = e.element; + if (void 0 === this.ft) return ((this.ft = new Set(Object.keys(r))), this.render(r)); + for (const t of this.ft) + null == r[t] && + (this.ft.delete(t), t.includes("-") ? s.removeProperty(t) : (s[t] = null)); + for (const t in r) { + const e = r[t]; + if (null != e) { + this.ft.add(t); + const r = "string" == typeof e && e.endsWith(i); + t.includes("-") || r + ? s.setProperty(t, r ? e.slice(0, -11) : e, r ? n$1 : "") + : (s[t] = e); + } + } + return E; + } + }, + ); +var __esDecorate$18 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers$18 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +(() => { + let _classDecorators = [t$1("a2ui-audioplayer")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _url_decorators; + let _url_initializers = []; + let _url_extraInitializers = []; + var Audio = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _url_decorators = [n$6()]; + __esDecorate$18( + this, + null, + _url_decorators, + { + kind: "accessor", + name: "url", + static: false, + private: false, + access: { + has: (obj) => "url" in obj, + get: (obj) => obj.url, + set: (obj, value) => { + obj.url = value; + }, + }, + metadata: _metadata, + }, + _url_initializers, + _url_extraInitializers, + ); + __esDecorate$18( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Audio = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #url_accessor_storage = __runInitializers$18(this, _url_initializers, null); + get url() { + return this.#url_accessor_storage; + } + set url(value) { + this.#url_accessor_storage = value; + } + static { + this.styles = [ + structuralStyles, + i$9` + * { + box-sizing: border-box; + } + + :host { + display: block; + flex: var(--weight); + min-height: 0; + overflow: auto; + } + + audio { + display: block; + width: 100%; + } + `, + ]; + } + #renderAudio() { + if (!this.url) return A; + if (this.url && typeof this.url === "object") { + if ("literalString" in this.url) return b`"; +}; +default_rules.code_block = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + return ( + "" + + escapeHtml(tokens[idx].content) + + "\n" + ); +}; +default_rules.fence = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + const info = token.info ? unescapeAll(token.info).trim() : ""; + let langName = ""; + let langAttrs = ""; + if (info) { + const arr = info.split(/(\s+)/g); + langName = arr[0]; + langAttrs = arr.slice(2).join(""); + } + let highlighted; + if (options.highlight) + highlighted = + options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content); + else highlighted = escapeHtml(token.content); + if (highlighted.indexOf("${highlighted}
\n`; + } + return `
${highlighted}
\n`; +}; +default_rules.image = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + token.attrs[token.attrIndex("alt")][1] = slf.renderInlineAsText(token.children, options, env); + return slf.renderToken(tokens, idx, options); +}; +default_rules.hardbreak = function (tokens, idx, options) { + return options.xhtmlOut ? "
\n" : "
\n"; +}; +default_rules.softbreak = function (tokens, idx, options) { + return options.breaks ? (options.xhtmlOut ? "
\n" : "
\n") : "\n"; +}; +default_rules.text = function (tokens, idx) { + return escapeHtml(tokens[idx].content); +}; +default_rules.html_block = function (tokens, idx) { + return tokens[idx].content; +}; +default_rules.html_inline = function (tokens, idx) { + return tokens[idx].content; +}; +/** + * new Renderer() + * + * Creates new [[Renderer]] instance and fill [[Renderer#rules]] with defaults. + **/ +function Renderer() { + /** + * Renderer#rules -> Object + * + * Contains render rules for tokens. Can be updated and extended. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.renderer.rules.strong_open = function () { return ''; }; + * md.renderer.rules.strong_close = function () { return ''; }; + * + * var result = md.renderInline(...); + * ``` + * + * Each rule is called as independent static function with fixed signature: + * + * ```javascript + * function my_token_render(tokens, idx, options, env, renderer) { + * // ... + * return renderedHTML; + * } + * ``` + * + * See [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs) + * for more details and examples. + **/ + this.rules = assign$1({}, default_rules); +} +/** + * Renderer.renderAttrs(token) -> String + * + * Render token attributes to string. + **/ +Renderer.prototype.renderAttrs = function renderAttrs(token) { + let i, l, result; + if (!token.attrs) return ""; + result = ""; + for (i = 0, l = token.attrs.length; i < l; i++) + result += " " + escapeHtml(token.attrs[i][0]) + '="' + escapeHtml(token.attrs[i][1]) + '"'; + return result; +}; +/** + * Renderer.renderToken(tokens, idx, options) -> String + * - tokens (Array): list of tokens + * - idx (Numbed): token index to render + * - options (Object): params of parser instance + * + * Default token renderer. Can be overriden by custom function + * in [[Renderer#rules]]. + **/ +Renderer.prototype.renderToken = function renderToken(tokens, idx, options) { + const token = tokens[idx]; + let result = ""; + if (token.hidden) return ""; + if (token.block && token.nesting !== -1 && idx && tokens[idx - 1].hidden) result += "\n"; + result += (token.nesting === -1 ? "\n" : ">"; + return result; +}; +/** + * Renderer.renderInline(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * The same as [[Renderer.render]], but for single token of `inline` type. + **/ +Renderer.prototype.renderInline = function (tokens, options, env) { + let result = ""; + const rules = this.rules; + for (let i = 0, len = tokens.length; i < len; i++) { + const type = tokens[i].type; + if (typeof rules[type] !== "undefined") result += rules[type](tokens, i, options, env, this); + else result += this.renderToken(tokens, i, options); + } + return result; +}; +/** internal + * Renderer.renderInlineAsText(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * Special kludge for image `alt` attributes to conform CommonMark spec. + * Don't try to use it! Spec requires to show `alt` content with stripped markup, + * instead of simple escaping. + **/ +Renderer.prototype.renderInlineAsText = function (tokens, options, env) { + let result = ""; + for (let i = 0, len = tokens.length; i < len; i++) + switch (tokens[i].type) { + case "text": + result += tokens[i].content; + break; + case "image": + result += this.renderInlineAsText(tokens[i].children, options, env); + break; + case "html_inline": + case "html_block": + result += tokens[i].content; + break; + case "softbreak": + case "hardbreak": + result += "\n"; + break; + default: + } + return result; +}; +/** + * Renderer.render(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * Takes token stream and generates HTML. Probably, you will never need to call + * this method directly. + **/ +Renderer.prototype.render = function (tokens, options, env) { + let result = ""; + const rules = this.rules; + for (let i = 0, len = tokens.length; i < len; i++) { + const type = tokens[i].type; + if (type === "inline") result += this.renderInline(tokens[i].children, options, env); + else if (typeof rules[type] !== "undefined") + result += rules[type](tokens, i, options, env, this); + else result += this.renderToken(tokens, i, options, env); + } + return result; +}; +/** + * class Ruler + * + * Helper class, used by [[MarkdownIt#core]], [[MarkdownIt#block]] and + * [[MarkdownIt#inline]] to manage sequences of functions (rules): + * + * - keep rules in defined order + * - assign the name to each rule + * - enable/disable rules + * - add/replace rules + * - allow assign rules to additional named chains (in the same) + * - cacheing lists of active rules + * + * You will not need use this class directly until write plugins. For simple + * rules control use [[MarkdownIt.disable]], [[MarkdownIt.enable]] and + * [[MarkdownIt.use]]. + **/ +/** + * new Ruler() + **/ +function Ruler() { + this.__rules__ = []; + this.__cache__ = null; +} +Ruler.prototype.__find__ = function (name) { + for (let i = 0; i < this.__rules__.length; i++) if (this.__rules__[i].name === name) return i; + return -1; +}; +Ruler.prototype.__compile__ = function () { + const self = this; + const chains = [""]; + self.__rules__.forEach(function (rule) { + if (!rule.enabled) return; + rule.alt.forEach(function (altName) { + if (chains.indexOf(altName) < 0) chains.push(altName); + }); + }); + self.__cache__ = {}; + chains.forEach(function (chain) { + self.__cache__[chain] = []; + self.__rules__.forEach(function (rule) { + if (!rule.enabled) return; + if (chain && rule.alt.indexOf(chain) < 0) return; + self.__cache__[chain].push(rule.fn); + }); + }); +}; +/** + * Ruler.at(name, fn [, options]) + * - name (String): rule name to replace. + * - fn (Function): new rule function. + * - options (Object): new rule options (not mandatory). + * + * Replace rule by name with new function & options. Throws error if name not + * found. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * Replace existing typographer replacement rule with new one: + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.core.ruler.at('replacements', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.at = function (name, fn, options) { + const index = this.__find__(name); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + name); + this.__rules__[index].fn = fn; + this.__rules__[index].alt = opt.alt || []; + this.__cache__ = null; +}; +/** + * Ruler.before(beforeName, ruleName, fn [, options]) + * - beforeName (String): new rule will be added before this one. + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Add new rule to chain before one with given name. See also + * [[Ruler.after]], [[Ruler.push]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.block.ruler.before('paragraph', 'my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.before = function (beforeName, ruleName, fn, options) { + const index = this.__find__(beforeName); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + beforeName); + this.__rules__.splice(index, 0, { + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.after(afterName, ruleName, fn [, options]) + * - afterName (String): new rule will be added after this one. + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Add new rule to chain after one with given name. See also + * [[Ruler.before]], [[Ruler.push]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.inline.ruler.after('text', 'my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.after = function (afterName, ruleName, fn, options) { + const index = this.__find__(afterName); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + afterName); + this.__rules__.splice(index + 1, 0, { + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.push(ruleName, fn [, options]) + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Push new rule to the end of chain. See also + * [[Ruler.before]], [[Ruler.after]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.core.ruler.push('my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.push = function (ruleName, fn, options) { + const opt = options || {}; + this.__rules__.push({ + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.enable(list [, ignoreInvalid]) -> Array + * - list (String|Array): list of rule names to enable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable rules with given names. If any rule name not found - throw Error. + * Errors can be disabled by second param. + * + * Returns list of found rule names (if no exception happened). + * + * See also [[Ruler.disable]], [[Ruler.enableOnly]]. + **/ +Ruler.prototype.enable = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + const result = []; + list.forEach(function (name) { + const idx = this.__find__(name); + if (idx < 0) { + if (ignoreInvalid) return; + throw new Error("Rules manager: invalid rule name " + name); + } + this.__rules__[idx].enabled = true; + result.push(name); + }, this); + this.__cache__ = null; + return result; +}; +/** + * Ruler.enableOnly(list [, ignoreInvalid]) + * - list (String|Array): list of rule names to enable (whitelist). + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable rules with given names, and disable everything else. If any rule name + * not found - throw Error. Errors can be disabled by second param. + * + * See also [[Ruler.disable]], [[Ruler.enable]]. + **/ +Ruler.prototype.enableOnly = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + this.__rules__.forEach(function (rule) { + rule.enabled = false; + }); + this.enable(list, ignoreInvalid); +}; +/** + * Ruler.disable(list [, ignoreInvalid]) -> Array + * - list (String|Array): list of rule names to disable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Disable rules with given names. If any rule name not found - throw Error. + * Errors can be disabled by second param. + * + * Returns list of found rule names (if no exception happened). + * + * See also [[Ruler.enable]], [[Ruler.enableOnly]]. + **/ +Ruler.prototype.disable = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + const result = []; + list.forEach(function (name) { + const idx = this.__find__(name); + if (idx < 0) { + if (ignoreInvalid) return; + throw new Error("Rules manager: invalid rule name " + name); + } + this.__rules__[idx].enabled = false; + result.push(name); + }, this); + this.__cache__ = null; + return result; +}; +/** + * Ruler.getRules(chainName) -> Array + * + * Return array of active functions (rules) for given chain name. It analyzes + * rules configuration, compiles caches if not exists and returns result. + * + * Default chain name is `''` (empty string). It can't be skipped. That's + * done intentionally, to keep signature monomorphic for high speed. + **/ +Ruler.prototype.getRules = function (chainName) { + if (this.__cache__ === null) this.__compile__(); + return this.__cache__[chainName] || []; +}; +/** + * class Token + **/ +/** + * new Token(type, tag, nesting) + * + * Create new token and fill passed properties. + **/ +function Token(type, tag, nesting) { + /** + * Token#type -> String + * + * Type of the token (string, e.g. "paragraph_open") + **/ + this.type = type; + /** + * Token#tag -> String + * + * html tag name, e.g. "p" + **/ + this.tag = tag; + /** + * Token#attrs -> Array + * + * Html attributes. Format: `[ [ name1, value1 ], [ name2, value2 ] ]` + **/ + this.attrs = null; + /** + * Token#map -> Array + * + * Source map info. Format: `[ line_begin, line_end ]` + **/ + this.map = null; + /** + * Token#nesting -> Number + * + * Level change (number in {-1, 0, 1} set), where: + * + * - `1` means the tag is opening + * - `0` means the tag is self-closing + * - `-1` means the tag is closing + **/ + this.nesting = nesting; + /** + * Token#level -> Number + * + * nesting level, the same as `state.level` + **/ + this.level = 0; + /** + * Token#children -> Array + * + * An array of child nodes (inline and img tokens) + **/ + this.children = null; + /** + * Token#content -> String + * + * In a case of self-closing tag (code, html, fence, etc.), + * it has contents of this tag. + **/ + this.content = ""; + /** + * Token#markup -> String + * + * '*' or '_' for emphasis, fence string for fence, etc. + **/ + this.markup = ""; + /** + * Token#info -> String + * + * Additional information: + * + * - Info string for "fence" tokens + * - The value "auto" for autolink "link_open" and "link_close" tokens + * - The string value of the item marker for ordered-list "list_item_open" tokens + **/ + this.info = ""; + /** + * Token#meta -> Object + * + * A place for plugins to store an arbitrary data + **/ + this.meta = null; + /** + * Token#block -> Boolean + * + * True for block-level tokens, false for inline tokens. + * Used in renderer to calculate line breaks + **/ + this.block = false; + /** + * Token#hidden -> Boolean + * + * If it's true, ignore this element when rendering. Used for tight lists + * to hide paragraphs. + **/ + this.hidden = false; +} +/** + * Token.attrIndex(name) -> Number + * + * Search attribute index by name. + **/ +Token.prototype.attrIndex = function attrIndex(name) { + if (!this.attrs) return -1; + const attrs = this.attrs; + for (let i = 0, len = attrs.length; i < len; i++) if (attrs[i][0] === name) return i; + return -1; +}; +/** + * Token.attrPush(attrData) + * + * Add `[ name, value ]` attribute to list. Init attrs if necessary + **/ +Token.prototype.attrPush = function attrPush(attrData) { + if (this.attrs) this.attrs.push(attrData); + else this.attrs = [attrData]; +}; +/** + * Token.attrSet(name, value) + * + * Set `name` attribute to `value`. Override old value if exists. + **/ +Token.prototype.attrSet = function attrSet(name, value) { + const idx = this.attrIndex(name); + const attrData = [name, value]; + if (idx < 0) this.attrPush(attrData); + else this.attrs[idx] = attrData; +}; +/** + * Token.attrGet(name) + * + * Get the value of attribute `name`, or null if it does not exist. + **/ +Token.prototype.attrGet = function attrGet(name) { + const idx = this.attrIndex(name); + let value = null; + if (idx >= 0) value = this.attrs[idx][1]; + return value; +}; +/** + * Token.attrJoin(name, value) + * + * Join value to existing attribute via space. Or create new attribute if not + * exists. Useful to operate with token classes. + **/ +Token.prototype.attrJoin = function attrJoin(name, value) { + const idx = this.attrIndex(name); + if (idx < 0) this.attrPush([name, value]); + else this.attrs[idx][1] = this.attrs[idx][1] + " " + value; +}; +function StateCore(src, md, env) { + this.src = src; + this.env = env; + this.tokens = []; + this.inlineMode = false; + this.md = md; +} +StateCore.prototype.Token = Token; +const NEWLINES_RE = /\r\n?|\n/g; +const NULL_RE = /\0/g; +function normalize(state) { + let str; + str = state.src.replace(NEWLINES_RE, "\n"); + str = str.replace(NULL_RE, "�"); + state.src = str; +} +function block(state) { + let token; + if (state.inlineMode) { + token = new state.Token("inline", "", 0); + token.content = state.src; + token.map = [0, 1]; + token.children = []; + state.tokens.push(token); + } else state.md.block.parse(state.src, state.md, state.env, state.tokens); +} +function inline(state) { + const tokens = state.tokens; + for (let i = 0, l = tokens.length; i < l; i++) { + const tok = tokens[i]; + if (tok.type === "inline") + state.md.inline.parse(tok.content, state.md, state.env, tok.children); + } +} +function isLinkOpen$1(str) { + return /^\s]/i.test(str); +} +function isLinkClose$1(str) { + return /^<\/a\s*>/i.test(str); +} +function linkify$1(state) { + const blockTokens = state.tokens; + if (!state.md.options.linkify) return; + for (let j = 0, l = blockTokens.length; j < l; j++) { + if (blockTokens[j].type !== "inline" || !state.md.linkify.pretest(blockTokens[j].content)) + continue; + let tokens = blockTokens[j].children; + let htmlLinkLevel = 0; + for (let i = tokens.length - 1; i >= 0; i--) { + const currentToken = tokens[i]; + if (currentToken.type === "link_close") { + i--; + while (tokens[i].level !== currentToken.level && tokens[i].type !== "link_open") i--; + continue; + } + if (currentToken.type === "html_inline") { + if (isLinkOpen$1(currentToken.content) && htmlLinkLevel > 0) htmlLinkLevel--; + if (isLinkClose$1(currentToken.content)) htmlLinkLevel++; + } + if (htmlLinkLevel > 0) continue; + if (currentToken.type === "text" && state.md.linkify.test(currentToken.content)) { + const text = currentToken.content; + let links = state.md.linkify.match(text); + const nodes = []; + let level = currentToken.level; + let lastPos = 0; + if ( + links.length > 0 && + links[0].index === 0 && + i > 0 && + tokens[i - 1].type === "text_special" + ) + links = links.slice(1); + for (let ln = 0; ln < links.length; ln++) { + const url = links[ln].url; + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) continue; + let urlText = links[ln].text; + if (!links[ln].schema) + urlText = state.md.normalizeLinkText("http://" + urlText).replace(/^http:\/\//, ""); + else if (links[ln].schema === "mailto:" && !/^mailto:/i.test(urlText)) + urlText = state.md.normalizeLinkText("mailto:" + urlText).replace(/^mailto:/, ""); + else urlText = state.md.normalizeLinkText(urlText); + const pos = links[ln].index; + if (pos > lastPos) { + const token = new state.Token("text", "", 0); + token.content = text.slice(lastPos, pos); + token.level = level; + nodes.push(token); + } + const token_o = new state.Token("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.level = level++; + token_o.markup = "linkify"; + token_o.info = "auto"; + nodes.push(token_o); + const token_t = new state.Token("text", "", 0); + token_t.content = urlText; + token_t.level = level; + nodes.push(token_t); + const token_c = new state.Token("link_close", "a", -1); + token_c.level = --level; + token_c.markup = "linkify"; + token_c.info = "auto"; + nodes.push(token_c); + lastPos = links[ln].lastIndex; + } + if (lastPos < text.length) { + const token = new state.Token("text", "", 0); + token.content = text.slice(lastPos); + token.level = level; + nodes.push(token); + } + blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes); + } + } + } +} +const RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--/; +const SCOPED_ABBR_TEST_RE = /\((c|tm|r)\)/i; +const SCOPED_ABBR_RE = /\((c|tm|r)\)/gi; +const SCOPED_ABBR = { + c: "©", + r: "®", + tm: "™", +}; +function replaceFn(match, name) { + return SCOPED_ABBR[name.toLowerCase()]; +} +function replace_scoped(inlineTokens) { + let inside_autolink = 0; + for (let i = inlineTokens.length - 1; i >= 0; i--) { + const token = inlineTokens[i]; + if (token.type === "text" && !inside_autolink) + token.content = token.content.replace(SCOPED_ABBR_RE, replaceFn); + if (token.type === "link_open" && token.info === "auto") inside_autolink--; + if (token.type === "link_close" && token.info === "auto") inside_autolink++; + } +} +function replace_rare(inlineTokens) { + let inside_autolink = 0; + for (let i = inlineTokens.length - 1; i >= 0; i--) { + const token = inlineTokens[i]; + if (token.type === "text" && !inside_autolink) { + if (RARE_RE.test(token.content)) + token.content = token.content + .replace(/\+-/g, "±") + .replace(/\.{2,}/g, "…") + .replace(/([?!])…/g, "$1..") + .replace(/([?!]){4,}/g, "$1$1$1") + .replace(/,{2,}/g, ",") + .replace(/(^|[^-])---(?=[^-]|$)/gm, "$1—") + .replace(/(^|\s)--(?=\s|$)/gm, "$1–") + .replace(/(^|[^-\s])--(?=[^-\s]|$)/gm, "$1–"); + } + if (token.type === "link_open" && token.info === "auto") inside_autolink--; + if (token.type === "link_close" && token.info === "auto") inside_autolink++; + } +} +function replace(state) { + let blkIdx; + if (!state.md.options.typographer) return; + for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { + if (state.tokens[blkIdx].type !== "inline") continue; + if (SCOPED_ABBR_TEST_RE.test(state.tokens[blkIdx].content)) + replace_scoped(state.tokens[blkIdx].children); + if (RARE_RE.test(state.tokens[blkIdx].content)) replace_rare(state.tokens[blkIdx].children); + } +} +const QUOTE_TEST_RE = /['"]/; +const QUOTE_RE = /['"]/g; +const APOSTROPHE = "’"; +function replaceAt(str, index, ch) { + return str.slice(0, index) + ch + str.slice(index + 1); +} +function process_inlines(tokens, state) { + let j; + const stack = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const thisLevel = tokens[i].level; + for (j = stack.length - 1; j >= 0; j--) if (stack[j].level <= thisLevel) break; + stack.length = j + 1; + if (token.type !== "text") continue; + let text = token.content; + let pos = 0; + let max = text.length; + OUTER: while (pos < max) { + QUOTE_RE.lastIndex = pos; + const t = QUOTE_RE.exec(text); + if (!t) break; + let canOpen = true; + let canClose = true; + pos = t.index + 1; + const isSingle = t[0] === "'"; + let lastChar = 32; + if (t.index - 1 >= 0) lastChar = text.charCodeAt(t.index - 1); + else + for (j = i - 1; j >= 0; j--) { + if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; + if (!tokens[j].content) continue; + lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1); + break; + } + let nextChar = 32; + if (pos < max) nextChar = text.charCodeAt(pos); + else + for (j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; + if (!tokens[j].content) continue; + nextChar = tokens[j].content.charCodeAt(0); + break; + } + const isLastPunctChar = + isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); + const isNextPunctChar = + isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); + const isLastWhiteSpace = isWhiteSpace(lastChar); + const isNextWhiteSpace = isWhiteSpace(nextChar); + if (isNextWhiteSpace) canOpen = false; + else if (isNextPunctChar) { + if (!(isLastWhiteSpace || isLastPunctChar)) canOpen = false; + } + if (isLastWhiteSpace) canClose = false; + else if (isLastPunctChar) { + if (!(isNextWhiteSpace || isNextPunctChar)) canClose = false; + } + if (nextChar === 34 && t[0] === '"') { + if (lastChar >= 48 && lastChar <= 57) canClose = canOpen = false; + } + if (canOpen && canClose) { + canOpen = isLastPunctChar; + canClose = isNextPunctChar; + } + if (!canOpen && !canClose) { + if (isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); + continue; + } + if (canClose) + for (j = stack.length - 1; j >= 0; j--) { + let item = stack[j]; + if (stack[j].level < thisLevel) break; + if (item.single === isSingle && stack[j].level === thisLevel) { + item = stack[j]; + let openQuote; + let closeQuote; + if (isSingle) { + openQuote = state.md.options.quotes[2]; + closeQuote = state.md.options.quotes[3]; + } else { + openQuote = state.md.options.quotes[0]; + closeQuote = state.md.options.quotes[1]; + } + token.content = replaceAt(token.content, t.index, closeQuote); + tokens[item.token].content = replaceAt(tokens[item.token].content, item.pos, openQuote); + pos += closeQuote.length - 1; + if (item.token === i) pos += openQuote.length - 1; + text = token.content; + max = text.length; + stack.length = j; + continue OUTER; + } + } + if (canOpen) + stack.push({ + token: i, + pos: t.index, + single: isSingle, + level: thisLevel, + }); + else if (canClose && isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); + } + } +} +function smartquotes(state) { + if (!state.md.options.typographer) return; + for (let blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { + if (state.tokens[blkIdx].type !== "inline" || !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) + continue; + process_inlines(state.tokens[blkIdx].children, state); + } +} +function text_join(state) { + let curr, last; + const blockTokens = state.tokens; + const l = blockTokens.length; + for (let j = 0; j < l; j++) { + if (blockTokens[j].type !== "inline") continue; + const tokens = blockTokens[j].children; + const max = tokens.length; + for (curr = 0; curr < max; curr++) + if (tokens[curr].type === "text_special") tokens[curr].type = "text"; + for (curr = last = 0; curr < max; curr++) + if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") + tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; + else { + if (curr !== last) tokens[last] = tokens[curr]; + last++; + } + if (curr !== last) tokens.length = last; + } +} +/** internal + * class Core + * + * Top-level rules executor. Glues block/inline parsers and does intermediate + * transformations. + **/ +const _rules$2 = [ + ["normalize", normalize], + ["block", block], + ["inline", inline], + ["linkify", linkify$1], + ["replacements", replace], + ["smartquotes", smartquotes], + ["text_join", text_join], +]; +/** + * new Core() + **/ +function Core() { + /** + * Core#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of core rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules$2.length; i++) this.ruler.push(_rules$2[i][0], _rules$2[i][1]); +} +/** + * Core.process(state) + * + * Executes core chain rules. + **/ +Core.prototype.process = function (state) { + const rules = this.ruler.getRules(""); + for (let i = 0, l = rules.length; i < l; i++) rules[i](state); +}; +Core.prototype.State = StateCore; +function StateBlock(src, md, env, tokens) { + this.src = src; + this.md = md; + this.env = env; + this.tokens = tokens; + this.bMarks = []; + this.eMarks = []; + this.tShift = []; + this.sCount = []; + this.bsCount = []; + this.blkIndent = 0; + this.line = 0; + this.lineMax = 0; + this.tight = false; + this.ddIndent = -1; + this.listIndent = -1; + this.parentType = "root"; + this.level = 0; + const s = this.src; + for ( + let start = 0, pos = 0, indent = 0, offset = 0, len = s.length, indent_found = false; + pos < len; + pos++ + ) { + const ch = s.charCodeAt(pos); + if (!indent_found) + if (isSpace(ch)) { + indent++; + if (ch === 9) offset += 4 - (offset % 4); + else offset++; + continue; + } else indent_found = true; + if (ch === 10 || pos === len - 1) { + if (ch !== 10) pos++; + this.bMarks.push(start); + this.eMarks.push(pos); + this.tShift.push(indent); + this.sCount.push(offset); + this.bsCount.push(0); + indent_found = false; + indent = 0; + offset = 0; + start = pos + 1; + } + } + this.bMarks.push(s.length); + this.eMarks.push(s.length); + this.tShift.push(0); + this.sCount.push(0); + this.bsCount.push(0); + this.lineMax = this.bMarks.length - 1; +} +StateBlock.prototype.push = function (type, tag, nesting) { + const token = new Token(type, tag, nesting); + token.block = true; + if (nesting < 0) this.level--; + token.level = this.level; + if (nesting > 0) this.level++; + this.tokens.push(token); + return token; +}; +StateBlock.prototype.isEmpty = function isEmpty(line) { + return this.bMarks[line] + this.tShift[line] >= this.eMarks[line]; +}; +StateBlock.prototype.skipEmptyLines = function skipEmptyLines(from) { + for (let max = this.lineMax; from < max; from++) + if (this.bMarks[from] + this.tShift[from] < this.eMarks[from]) break; + return from; +}; +StateBlock.prototype.skipSpaces = function skipSpaces(pos) { + for (let max = this.src.length; pos < max; pos++) if (!isSpace(this.src.charCodeAt(pos))) break; + return pos; +}; +StateBlock.prototype.skipSpacesBack = function skipSpacesBack(pos, min) { + if (pos <= min) return pos; + while (pos > min) if (!isSpace(this.src.charCodeAt(--pos))) return pos + 1; + return pos; +}; +StateBlock.prototype.skipChars = function skipChars(pos, code) { + for (let max = this.src.length; pos < max; pos++) if (this.src.charCodeAt(pos) !== code) break; + return pos; +}; +StateBlock.prototype.skipCharsBack = function skipCharsBack(pos, code, min) { + if (pos <= min) return pos; + while (pos > min) if (code !== this.src.charCodeAt(--pos)) return pos + 1; + return pos; +}; +StateBlock.prototype.getLines = function getLines(begin, end, indent, keepLastLF) { + if (begin >= end) return ""; + const queue = new Array(end - begin); + for (let i = 0, line = begin; line < end; line++, i++) { + let lineIndent = 0; + const lineStart = this.bMarks[line]; + let first = lineStart; + let last; + if (line + 1 < end || keepLastLF) last = this.eMarks[line] + 1; + else last = this.eMarks[line]; + while (first < last && lineIndent < indent) { + const ch = this.src.charCodeAt(first); + if (isSpace(ch)) + if (ch === 9) lineIndent += 4 - ((lineIndent + this.bsCount[line]) % 4); + else lineIndent++; + else if (first - lineStart < this.tShift[line]) lineIndent++; + else break; + first++; + } + if (lineIndent > indent) + queue[i] = new Array(lineIndent - indent + 1).join(" ") + this.src.slice(first, last); + else queue[i] = this.src.slice(first, last); + } + return queue.join(""); +}; +StateBlock.prototype.Token = Token; +const MAX_AUTOCOMPLETED_CELLS = 65536; +function getLine(state, line) { + const pos = state.bMarks[line] + state.tShift[line]; + const max = state.eMarks[line]; + return state.src.slice(pos, max); +} +function escapedSplit(str) { + const result = []; + const max = str.length; + let pos = 0; + let ch = str.charCodeAt(pos); + let isEscaped = false; + let lastPos = 0; + let current = ""; + while (pos < max) { + if (ch === 124) + if (!isEscaped) { + result.push(current + str.substring(lastPos, pos)); + current = ""; + lastPos = pos + 1; + } else { + current += str.substring(lastPos, pos - 1); + lastPos = pos; + } + isEscaped = ch === 92; + pos++; + ch = str.charCodeAt(pos); + } + result.push(current + str.substring(lastPos)); + return result; +} +function table(state, startLine, endLine, silent) { + if (startLine + 2 > endLine) return false; + let nextLine = startLine + 1; + if (state.sCount[nextLine] < state.blkIndent) return false; + if (state.sCount[nextLine] - state.blkIndent >= 4) return false; + let pos = state.bMarks[nextLine] + state.tShift[nextLine]; + if (pos >= state.eMarks[nextLine]) return false; + const firstCh = state.src.charCodeAt(pos++); + if (firstCh !== 124 && firstCh !== 45 && firstCh !== 58) return false; + if (pos >= state.eMarks[nextLine]) return false; + const secondCh = state.src.charCodeAt(pos++); + if (secondCh !== 124 && secondCh !== 45 && secondCh !== 58 && !isSpace(secondCh)) return false; + if (firstCh === 45 && isSpace(secondCh)) return false; + while (pos < state.eMarks[nextLine]) { + const ch = state.src.charCodeAt(pos); + if (ch !== 124 && ch !== 45 && ch !== 58 && !isSpace(ch)) return false; + pos++; + } + let lineText = getLine(state, startLine + 1); + let columns = lineText.split("|"); + const aligns = []; + for (let i = 0; i < columns.length; i++) { + const t = columns[i].trim(); + if (!t) + if (i === 0 || i === columns.length - 1) continue; + else return false; + if (!/^:?-+:?$/.test(t)) return false; + if (t.charCodeAt(t.length - 1) === 58) aligns.push(t.charCodeAt(0) === 58 ? "center" : "right"); + else if (t.charCodeAt(0) === 58) aligns.push("left"); + else aligns.push(""); + } + lineText = getLine(state, startLine).trim(); + if (lineText.indexOf("|") === -1) return false; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + columns = escapedSplit(lineText); + if (columns.length && columns[0] === "") columns.shift(); + if (columns.length && columns[columns.length - 1] === "") columns.pop(); + const columnCount = columns.length; + if (columnCount === 0 || columnCount !== aligns.length) return false; + if (silent) return true; + const oldParentType = state.parentType; + state.parentType = "table"; + const terminatorRules = state.md.block.ruler.getRules("blockquote"); + const token_to = state.push("table_open", "table", 1); + const tableLines = [startLine, 0]; + token_to.map = tableLines; + const token_tho = state.push("thead_open", "thead", 1); + token_tho.map = [startLine, startLine + 1]; + const token_htro = state.push("tr_open", "tr", 1); + token_htro.map = [startLine, startLine + 1]; + for (let i = 0; i < columns.length; i++) { + const token_ho = state.push("th_open", "th", 1); + if (aligns[i]) token_ho.attrs = [["style", "text-align:" + aligns[i]]]; + const token_il = state.push("inline", "", 0); + token_il.content = columns[i].trim(); + token_il.children = []; + state.push("th_close", "th", -1); + } + state.push("tr_close", "tr", -1); + state.push("thead_close", "thead", -1); + let tbodyLines; + let autocompletedCells = 0; + for (nextLine = startLine + 2; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + lineText = getLine(state, nextLine).trim(); + if (!lineText) break; + if (state.sCount[nextLine] - state.blkIndent >= 4) break; + columns = escapedSplit(lineText); + if (columns.length && columns[0] === "") columns.shift(); + if (columns.length && columns[columns.length - 1] === "") columns.pop(); + autocompletedCells += columnCount - columns.length; + if (autocompletedCells > MAX_AUTOCOMPLETED_CELLS) break; + if (nextLine === startLine + 2) { + const token_tbo = state.push("tbody_open", "tbody", 1); + token_tbo.map = tbodyLines = [startLine + 2, 0]; + } + const token_tro = state.push("tr_open", "tr", 1); + token_tro.map = [nextLine, nextLine + 1]; + for (let i = 0; i < columnCount; i++) { + const token_tdo = state.push("td_open", "td", 1); + if (aligns[i]) token_tdo.attrs = [["style", "text-align:" + aligns[i]]]; + const token_il = state.push("inline", "", 0); + token_il.content = columns[i] ? columns[i].trim() : ""; + token_il.children = []; + state.push("td_close", "td", -1); + } + state.push("tr_close", "tr", -1); + } + if (tbodyLines) { + state.push("tbody_close", "tbody", -1); + tbodyLines[1] = nextLine; + } + state.push("table_close", "table", -1); + tableLines[1] = nextLine; + state.parentType = oldParentType; + state.line = nextLine; + return true; +} +function code(state, startLine, endLine) { + if (state.sCount[startLine] - state.blkIndent < 4) return false; + let nextLine = startLine + 1; + let last = nextLine; + while (nextLine < endLine) { + if (state.isEmpty(nextLine)) { + nextLine++; + continue; + } + if (state.sCount[nextLine] - state.blkIndent >= 4) { + nextLine++; + last = nextLine; + continue; + } + break; + } + state.line = last; + const token = state.push("code_block", "code", 0); + token.content = state.getLines(startLine, last, 4 + state.blkIndent, false) + "\n"; + token.map = [startLine, state.line]; + return true; +} +function fence(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (pos + 3 > max) return false; + const marker = state.src.charCodeAt(pos); + if (marker !== 126 && marker !== 96) return false; + let mem = pos; + pos = state.skipChars(pos, marker); + let len = pos - mem; + if (len < 3) return false; + const markup = state.src.slice(mem, pos); + const params = state.src.slice(pos, max); + if (marker === 96) { + if (params.indexOf(String.fromCharCode(marker)) >= 0) return false; + } + if (silent) return true; + let nextLine = startLine; + let haveEndMarker = false; + for (;;) { + nextLine++; + if (nextLine >= endLine) break; + pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + if (pos < max && state.sCount[nextLine] < state.blkIndent) break; + if (state.src.charCodeAt(pos) !== marker) continue; + if (state.sCount[nextLine] - state.blkIndent >= 4) continue; + pos = state.skipChars(pos, marker); + if (pos - mem < len) continue; + pos = state.skipSpaces(pos); + if (pos < max) continue; + haveEndMarker = true; + break; + } + len = state.sCount[startLine]; + state.line = nextLine + (haveEndMarker ? 1 : 0); + const token = state.push("fence", "code", 0); + token.info = params; + token.content = state.getLines(startLine + 1, nextLine, len, true); + token.markup = markup; + token.map = [startLine, state.line]; + return true; +} +function blockquote(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + const oldLineMax = state.lineMax; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (state.src.charCodeAt(pos) !== 62) return false; + if (silent) return true; + const oldBMarks = []; + const oldBSCount = []; + const oldSCount = []; + const oldTShift = []; + const terminatorRules = state.md.block.ruler.getRules("blockquote"); + const oldParentType = state.parentType; + state.parentType = "blockquote"; + let lastLineEmpty = false; + let nextLine; + for (nextLine = startLine; nextLine < endLine; nextLine++) { + const isOutdented = state.sCount[nextLine] < state.blkIndent; + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + if (pos >= max) break; + if (state.src.charCodeAt(pos++) === 62 && !isOutdented) { + let initial = state.sCount[nextLine] + 1; + let spaceAfterMarker; + let adjustTab; + if (state.src.charCodeAt(pos) === 32) { + pos++; + initial++; + adjustTab = false; + spaceAfterMarker = true; + } else if (state.src.charCodeAt(pos) === 9) { + spaceAfterMarker = true; + if ((state.bsCount[nextLine] + initial) % 4 === 3) { + pos++; + initial++; + adjustTab = false; + } else adjustTab = true; + } else spaceAfterMarker = false; + let offset = initial; + oldBMarks.push(state.bMarks[nextLine]); + state.bMarks[nextLine] = pos; + while (pos < max) { + const ch = state.src.charCodeAt(pos); + if (isSpace(ch)) + if (ch === 9) + offset += 4 - ((offset + state.bsCount[nextLine] + (adjustTab ? 1 : 0)) % 4); + else offset++; + else break; + pos++; + } + lastLineEmpty = pos >= max; + oldBSCount.push(state.bsCount[nextLine]); + state.bsCount[nextLine] = state.sCount[nextLine] + 1 + (spaceAfterMarker ? 1 : 0); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] = offset - initial; + oldTShift.push(state.tShift[nextLine]); + state.tShift[nextLine] = pos - state.bMarks[nextLine]; + continue; + } + if (lastLineEmpty) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) { + state.lineMax = nextLine; + if (state.blkIndent !== 0) { + oldBMarks.push(state.bMarks[nextLine]); + oldBSCount.push(state.bsCount[nextLine]); + oldTShift.push(state.tShift[nextLine]); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] -= state.blkIndent; + } + break; + } + oldBMarks.push(state.bMarks[nextLine]); + oldBSCount.push(state.bsCount[nextLine]); + oldTShift.push(state.tShift[nextLine]); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] = -1; + } + const oldIndent = state.blkIndent; + state.blkIndent = 0; + const token_o = state.push("blockquote_open", "blockquote", 1); + token_o.markup = ">"; + const lines = [startLine, 0]; + token_o.map = lines; + state.md.block.tokenize(state, startLine, nextLine); + const token_c = state.push("blockquote_close", "blockquote", -1); + token_c.markup = ">"; + state.lineMax = oldLineMax; + state.parentType = oldParentType; + lines[1] = state.line; + for (let i = 0; i < oldTShift.length; i++) { + state.bMarks[i + startLine] = oldBMarks[i]; + state.tShift[i + startLine] = oldTShift[i]; + state.sCount[i + startLine] = oldSCount[i]; + state.bsCount[i + startLine] = oldBSCount[i]; + } + state.blkIndent = oldIndent; + return true; +} +function hr(state, startLine, endLine, silent) { + const max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + let pos = state.bMarks[startLine] + state.tShift[startLine]; + const marker = state.src.charCodeAt(pos++); + if (marker !== 42 && marker !== 45 && marker !== 95) return false; + let cnt = 1; + while (pos < max) { + const ch = state.src.charCodeAt(pos++); + if (ch !== marker && !isSpace(ch)) return false; + if (ch === marker) cnt++; + } + if (cnt < 3) return false; + if (silent) return true; + state.line = startLine + 1; + const token = state.push("hr", "hr", 0); + token.map = [startLine, state.line]; + token.markup = Array(cnt + 1).join(String.fromCharCode(marker)); + return true; +} +function skipBulletListMarker(state, startLine) { + const max = state.eMarks[startLine]; + let pos = state.bMarks[startLine] + state.tShift[startLine]; + const marker = state.src.charCodeAt(pos++); + if (marker !== 42 && marker !== 45 && marker !== 43) return -1; + if (pos < max) { + if (!isSpace(state.src.charCodeAt(pos))) return -1; + } + return pos; +} +function skipOrderedListMarker(state, startLine) { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + let pos = start; + if (pos + 1 >= max) return -1; + let ch = state.src.charCodeAt(pos++); + if (ch < 48 || ch > 57) return -1; + for (;;) { + if (pos >= max) return -1; + ch = state.src.charCodeAt(pos++); + if (ch >= 48 && ch <= 57) { + if (pos - start >= 10) return -1; + continue; + } + if (ch === 41 || ch === 46) break; + return -1; + } + if (pos < max) { + ch = state.src.charCodeAt(pos); + if (!isSpace(ch)) return -1; + } + return pos; +} +function markTightParagraphs(state, idx) { + const level = state.level + 2; + for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) + if (state.tokens[i].level === level && state.tokens[i].type === "paragraph_open") { + state.tokens[i + 2].hidden = true; + state.tokens[i].hidden = true; + i += 2; + } +} +function list(state, startLine, endLine, silent) { + let max, pos, start, token; + let nextLine = startLine; + let tight = true; + if (state.sCount[nextLine] - state.blkIndent >= 4) return false; + if ( + state.listIndent >= 0 && + state.sCount[nextLine] - state.listIndent >= 4 && + state.sCount[nextLine] < state.blkIndent + ) + return false; + let isTerminatingParagraph = false; + if (silent && state.parentType === "paragraph") { + if (state.sCount[nextLine] >= state.blkIndent) isTerminatingParagraph = true; + } + let isOrdered; + let markerValue; + let posAfterMarker; + if ((posAfterMarker = skipOrderedListMarker(state, nextLine)) >= 0) { + isOrdered = true; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + markerValue = Number(state.src.slice(start, posAfterMarker - 1)); + if (isTerminatingParagraph && markerValue !== 1) return false; + } else if ((posAfterMarker = skipBulletListMarker(state, nextLine)) >= 0) isOrdered = false; + else return false; + if (isTerminatingParagraph) { + if (state.skipSpaces(posAfterMarker) >= state.eMarks[nextLine]) return false; + } + if (silent) return true; + const markerCharCode = state.src.charCodeAt(posAfterMarker - 1); + const listTokIdx = state.tokens.length; + if (isOrdered) { + token = state.push("ordered_list_open", "ol", 1); + if (markerValue !== 1) token.attrs = [["start", markerValue]]; + } else token = state.push("bullet_list_open", "ul", 1); + const listLines = [nextLine, 0]; + token.map = listLines; + token.markup = String.fromCharCode(markerCharCode); + let prevEmptyEnd = false; + const terminatorRules = state.md.block.ruler.getRules("list"); + const oldParentType = state.parentType; + state.parentType = "list"; + while (nextLine < endLine) { + pos = posAfterMarker; + max = state.eMarks[nextLine]; + const initial = + state.sCount[nextLine] + posAfterMarker - (state.bMarks[nextLine] + state.tShift[nextLine]); + let offset = initial; + while (pos < max) { + const ch = state.src.charCodeAt(pos); + if (ch === 9) offset += 4 - ((offset + state.bsCount[nextLine]) % 4); + else if (ch === 32) offset++; + else break; + pos++; + } + const contentStart = pos; + let indentAfterMarker; + if (contentStart >= max) indentAfterMarker = 1; + else indentAfterMarker = offset - initial; + if (indentAfterMarker > 4) indentAfterMarker = 1; + const indent = initial + indentAfterMarker; + token = state.push("list_item_open", "li", 1); + token.markup = String.fromCharCode(markerCharCode); + const itemLines = [nextLine, 0]; + token.map = itemLines; + if (isOrdered) token.info = state.src.slice(start, posAfterMarker - 1); + const oldTight = state.tight; + const oldTShift = state.tShift[nextLine]; + const oldSCount = state.sCount[nextLine]; + const oldListIndent = state.listIndent; + state.listIndent = state.blkIndent; + state.blkIndent = indent; + state.tight = true; + state.tShift[nextLine] = contentStart - state.bMarks[nextLine]; + state.sCount[nextLine] = offset; + if (contentStart >= max && state.isEmpty(nextLine + 1)) + state.line = Math.min(state.line + 2, endLine); + else state.md.block.tokenize(state, nextLine, endLine, true); + if (!state.tight || prevEmptyEnd) tight = false; + prevEmptyEnd = state.line - nextLine > 1 && state.isEmpty(state.line - 1); + state.blkIndent = state.listIndent; + state.listIndent = oldListIndent; + state.tShift[nextLine] = oldTShift; + state.sCount[nextLine] = oldSCount; + state.tight = oldTight; + token = state.push("list_item_close", "li", -1); + token.markup = String.fromCharCode(markerCharCode); + nextLine = state.line; + itemLines[1] = nextLine; + if (nextLine >= endLine) break; + if (state.sCount[nextLine] < state.blkIndent) break; + if (state.sCount[nextLine] - state.blkIndent >= 4) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + if (isOrdered) { + posAfterMarker = skipOrderedListMarker(state, nextLine); + if (posAfterMarker < 0) break; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + } else { + posAfterMarker = skipBulletListMarker(state, nextLine); + if (posAfterMarker < 0) break; + } + if (markerCharCode !== state.src.charCodeAt(posAfterMarker - 1)) break; + } + if (isOrdered) token = state.push("ordered_list_close", "ol", -1); + else token = state.push("bullet_list_close", "ul", -1); + token.markup = String.fromCharCode(markerCharCode); + listLines[1] = nextLine; + state.line = nextLine; + state.parentType = oldParentType; + if (tight) markTightParagraphs(state, listTokIdx); + return true; +} +function reference(state, startLine, _endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + let nextLine = startLine + 1; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (state.src.charCodeAt(pos) !== 91) return false; + function getNextLine(nextLine) { + const endLine = state.lineMax; + if (nextLine >= endLine || state.isEmpty(nextLine)) return null; + let isContinuation = false; + if (state.sCount[nextLine] - state.blkIndent > 3) isContinuation = true; + if (state.sCount[nextLine] < 0) isContinuation = true; + if (!isContinuation) { + const terminatorRules = state.md.block.ruler.getRules("reference"); + const oldParentType = state.parentType; + state.parentType = "reference"; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + state.parentType = oldParentType; + if (terminate) return null; + } + const pos = state.bMarks[nextLine] + state.tShift[nextLine]; + const max = state.eMarks[nextLine]; + return state.src.slice(pos, max + 1); + } + let str = state.src.slice(pos, max + 1); + max = str.length; + let labelEnd = -1; + for (pos = 1; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 91) return false; + else if (ch === 93) { + labelEnd = pos; + break; + } else if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (ch === 92) { + pos++; + if (pos < max && str.charCodeAt(pos) === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } + } + } + if (labelEnd < 0 || str.charCodeAt(labelEnd + 1) !== 58) return false; + for (pos = labelEnd + 2; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (isSpace(ch)) { + } else break; + } + const destRes = state.md.helpers.parseLinkDestination(str, pos, max); + if (!destRes.ok) return false; + const href = state.md.normalizeLink(destRes.str); + if (!state.md.validateLink(href)) return false; + pos = destRes.pos; + const destEndPos = pos; + const destEndLineNo = nextLine; + const start = pos; + for (; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (isSpace(ch)) { + } else break; + } + let titleRes = state.md.helpers.parseLinkTitle(str, pos, max); + while (titleRes.can_continue) { + const lineContent = getNextLine(nextLine); + if (lineContent === null) break; + str += lineContent; + pos = max; + max = str.length; + nextLine++; + titleRes = state.md.helpers.parseLinkTitle(str, pos, max, titleRes); + } + let title; + if (pos < max && start !== pos && titleRes.ok) { + title = titleRes.str; + pos = titleRes.pos; + } else { + title = ""; + pos = destEndPos; + nextLine = destEndLineNo; + } + while (pos < max) { + if (!isSpace(str.charCodeAt(pos))) break; + pos++; + } + if (pos < max && str.charCodeAt(pos) !== 10) { + if (title) { + title = ""; + pos = destEndPos; + nextLine = destEndLineNo; + while (pos < max) { + if (!isSpace(str.charCodeAt(pos))) break; + pos++; + } + } + } + if (pos < max && str.charCodeAt(pos) !== 10) return false; + const label = normalizeReference(str.slice(1, labelEnd)); + if (!label) return false; + /* istanbul ignore if */ + if (silent) return true; + if (typeof state.env.references === "undefined") state.env.references = {}; + if (typeof state.env.references[label] === "undefined") + state.env.references[label] = { + title, + href, + }; + state.line = nextLine; + return true; +} +var html_blocks_default = [ + "address", + "article", + "aside", + "base", + "basefont", + "blockquote", + "body", + "caption", + "center", + "col", + "colgroup", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "frame", + "frameset", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hr", + "html", + "iframe", + "legend", + "li", + "link", + "main", + "menu", + "menuitem", + "nav", + "noframes", + "ol", + "optgroup", + "option", + "p", + "param", + "search", + "section", + "summary", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "title", + "tr", + "track", + "ul", +]; +const open_tag = + "<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>"; +const HTML_TAG_RE = new RegExp( + "^(?:" + + open_tag + + "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>||<[?][\\s\\S]*?[?]>|]*>|)", +); +const HTML_OPEN_CLOSE_TAG_RE = new RegExp("^(?:" + open_tag + "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)"); +const HTML_SEQUENCES = [ + [/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true], + [/^/, true], + [/^<\?/, /\?>/, true], + [/^/, true], + [/^/, true], + [new RegExp("^|$))", "i"), /^$/, true], + [new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + "\\s*$"), /^$/, false], +]; +function html_block(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (!state.md.options.html) return false; + if (state.src.charCodeAt(pos) !== 60) return false; + let lineText = state.src.slice(pos, max); + let i = 0; + for (; i < HTML_SEQUENCES.length; i++) if (HTML_SEQUENCES[i][0].test(lineText)) break; + if (i === HTML_SEQUENCES.length) return false; + if (silent) return HTML_SEQUENCES[i][2]; + let nextLine = startLine + 1; + if (!HTML_SEQUENCES[i][1].test(lineText)) + for (; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) break; + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + lineText = state.src.slice(pos, max); + if (HTML_SEQUENCES[i][1].test(lineText)) { + if (lineText.length !== 0) nextLine++; + break; + } + } + state.line = nextLine; + const token = state.push("html_block", "", 0); + token.map = [startLine, nextLine]; + token.content = state.getLines(startLine, nextLine, state.blkIndent, true); + return true; +} +function heading(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + let ch = state.src.charCodeAt(pos); + if (ch !== 35 || pos >= max) return false; + let level = 1; + ch = state.src.charCodeAt(++pos); + while (ch === 35 && pos < max && level <= 6) { + level++; + ch = state.src.charCodeAt(++pos); + } + if (level > 6 || (pos < max && !isSpace(ch))) return false; + if (silent) return true; + max = state.skipSpacesBack(max, pos); + const tmp = state.skipCharsBack(max, 35, pos); + if (tmp > pos && isSpace(state.src.charCodeAt(tmp - 1))) max = tmp; + state.line = startLine + 1; + const token_o = state.push("heading_open", "h" + String(level), 1); + token_o.markup = "########".slice(0, level); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = state.src.slice(pos, max).trim(); + token_i.map = [startLine, state.line]; + token_i.children = []; + const token_c = state.push("heading_close", "h" + String(level), -1); + token_c.markup = "########".slice(0, level); + return true; +} +function lheading(state, startLine, endLine) { + const terminatorRules = state.md.block.ruler.getRules("paragraph"); + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + const oldParentType = state.parentType; + state.parentType = "paragraph"; + let level = 0; + let marker; + let nextLine = startLine + 1; + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { + if (state.sCount[nextLine] - state.blkIndent > 3) continue; + if (state.sCount[nextLine] >= state.blkIndent) { + let pos = state.bMarks[nextLine] + state.tShift[nextLine]; + const max = state.eMarks[nextLine]; + if (pos < max) { + marker = state.src.charCodeAt(pos); + if (marker === 45 || marker === 61) { + pos = state.skipChars(pos, marker); + pos = state.skipSpaces(pos); + if (pos >= max) { + level = marker === 61 ? 1 : 2; + break; + } + } + } + } + if (state.sCount[nextLine] < 0) continue; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + } + if (!level) return false; + const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); + state.line = nextLine + 1; + const token_o = state.push("heading_open", "h" + String(level), 1); + token_o.markup = String.fromCharCode(marker); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = content; + token_i.map = [startLine, state.line - 1]; + token_i.children = []; + const token_c = state.push("heading_close", "h" + String(level), -1); + token_c.markup = String.fromCharCode(marker); + state.parentType = oldParentType; + return true; +} +function paragraph(state, startLine, endLine) { + const terminatorRules = state.md.block.ruler.getRules("paragraph"); + const oldParentType = state.parentType; + let nextLine = startLine + 1; + state.parentType = "paragraph"; + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { + if (state.sCount[nextLine] - state.blkIndent > 3) continue; + if (state.sCount[nextLine] < 0) continue; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + } + const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); + state.line = nextLine; + const token_o = state.push("paragraph_open", "p", 1); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = content; + token_i.map = [startLine, state.line]; + token_i.children = []; + state.push("paragraph_close", "p", -1); + state.parentType = oldParentType; + return true; +} +/** internal + * class ParserBlock + * + * Block-level tokenizer. + **/ +const _rules$1 = [ + ["table", table, ["paragraph", "reference"]], + ["code", code], + ["fence", fence, ["paragraph", "reference", "blockquote", "list"]], + ["blockquote", blockquote, ["paragraph", "reference", "blockquote", "list"]], + ["hr", hr, ["paragraph", "reference", "blockquote", "list"]], + ["list", list, ["paragraph", "reference", "blockquote"]], + ["reference", reference], + ["html_block", html_block, ["paragraph", "reference", "blockquote"]], + ["heading", heading, ["paragraph", "reference", "blockquote"]], + ["lheading", lheading], + ["paragraph", paragraph], +]; +/** + * new ParserBlock() + **/ +function ParserBlock() { + /** + * ParserBlock#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of block rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules$1.length; i++) + this.ruler.push(_rules$1[i][0], _rules$1[i][1], { alt: (_rules$1[i][2] || []).slice() }); +} +ParserBlock.prototype.tokenize = function (state, startLine, endLine) { + const rules = this.ruler.getRules(""); + const len = rules.length; + const maxNesting = state.md.options.maxNesting; + let line = startLine; + let hasEmptyLines = false; + while (line < endLine) { + state.line = line = state.skipEmptyLines(line); + if (line >= endLine) break; + if (state.sCount[line] < state.blkIndent) break; + if (state.level >= maxNesting) { + state.line = endLine; + break; + } + const prevLine = state.line; + let ok = false; + for (let i = 0; i < len; i++) { + ok = rules[i](state, line, endLine, false); + if (ok) { + if (prevLine >= state.line) throw new Error("block rule didn't increment state.line"); + break; + } + } + if (!ok) throw new Error("none of the block rules matched"); + state.tight = !hasEmptyLines; + if (state.isEmpty(state.line - 1)) hasEmptyLines = true; + line = state.line; + if (line < endLine && state.isEmpty(line)) { + hasEmptyLines = true; + line++; + state.line = line; + } + } +}; +/** + * ParserBlock.parse(str, md, env, outTokens) + * + * Process input string and push block tokens into `outTokens` + **/ +ParserBlock.prototype.parse = function (src, md, env, outTokens) { + if (!src) return; + const state = new this.State(src, md, env, outTokens); + this.tokenize(state, state.line, state.lineMax); +}; +ParserBlock.prototype.State = StateBlock; +function StateInline(src, md, env, outTokens) { + this.src = src; + this.env = env; + this.md = md; + this.tokens = outTokens; + this.tokens_meta = Array(outTokens.length); + this.pos = 0; + this.posMax = this.src.length; + this.level = 0; + this.pending = ""; + this.pendingLevel = 0; + this.cache = {}; + this.delimiters = []; + this._prev_delimiters = []; + this.backticks = {}; + this.backticksScanned = false; + this.linkLevel = 0; +} +StateInline.prototype.pushPending = function () { + const token = new Token("text", "", 0); + token.content = this.pending; + token.level = this.pendingLevel; + this.tokens.push(token); + this.pending = ""; + return token; +}; +StateInline.prototype.push = function (type, tag, nesting) { + if (this.pending) this.pushPending(); + const token = new Token(type, tag, nesting); + let token_meta = null; + if (nesting < 0) { + this.level--; + this.delimiters = this._prev_delimiters.pop(); + } + token.level = this.level; + if (nesting > 0) { + this.level++; + this._prev_delimiters.push(this.delimiters); + this.delimiters = []; + token_meta = { delimiters: this.delimiters }; + } + this.pendingLevel = this.level; + this.tokens.push(token); + this.tokens_meta.push(token_meta); + return token; +}; +StateInline.prototype.scanDelims = function (start, canSplitWord) { + const max = this.posMax; + const marker = this.src.charCodeAt(start); + const lastChar = start > 0 ? this.src.charCodeAt(start - 1) : 32; + let pos = start; + while (pos < max && this.src.charCodeAt(pos) === marker) pos++; + const count = pos - start; + const nextChar = pos < max ? this.src.charCodeAt(pos) : 32; + const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); + const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); + const isLastWhiteSpace = isWhiteSpace(lastChar); + const isNextWhiteSpace = isWhiteSpace(nextChar); + const left_flanking = + !isNextWhiteSpace && (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar); + const right_flanking = + !isLastWhiteSpace && (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar); + return { + can_open: left_flanking && (canSplitWord || !right_flanking || isLastPunctChar), + can_close: right_flanking && (canSplitWord || !left_flanking || isNextPunctChar), + length: count, + }; +}; +StateInline.prototype.Token = Token; +function isTerminatorChar(ch) { + switch (ch) { + case 10: + case 33: + case 35: + case 36: + case 37: + case 38: + case 42: + case 43: + case 45: + case 58: + case 60: + case 61: + case 62: + case 64: + case 91: + case 92: + case 93: + case 94: + case 95: + case 96: + case 123: + case 125: + case 126: + return true; + default: + return false; + } +} +function text(state, silent) { + let pos = state.pos; + while (pos < state.posMax && !isTerminatorChar(state.src.charCodeAt(pos))) pos++; + if (pos === state.pos) return false; + if (!silent) state.pending += state.src.slice(state.pos, pos); + state.pos = pos; + return true; +} +const SCHEME_RE = /(?:^|[^a-z0-9.+-])([a-z][a-z0-9.+-]*)$/i; +function linkify(state, silent) { + if (!state.md.options.linkify) return false; + if (state.linkLevel > 0) return false; + const pos = state.pos; + const max = state.posMax; + if (pos + 3 > max) return false; + if (state.src.charCodeAt(pos) !== 58) return false; + if (state.src.charCodeAt(pos + 1) !== 47) return false; + if (state.src.charCodeAt(pos + 2) !== 47) return false; + const match = state.pending.match(SCHEME_RE); + if (!match) return false; + const proto = match[1]; + const link = state.md.linkify.matchAtStart(state.src.slice(pos - proto.length)); + if (!link) return false; + let url = link.url; + if (url.length <= proto.length) return false; + let urlEnd = url.length; + while (urlEnd > 0 && url.charCodeAt(urlEnd - 1) === 42) urlEnd--; + if (urlEnd !== url.length) url = url.slice(0, urlEnd); + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + state.pending = state.pending.slice(0, -proto.length); + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "linkify"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "linkify"; + token_c.info = "auto"; + } + state.pos += url.length - proto.length; + return true; +} +function newline(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 10) return false; + const pmax = state.pending.length - 1; + const max = state.posMax; + if (!silent) + if (pmax >= 0 && state.pending.charCodeAt(pmax) === 32) + if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 32) { + let ws = pmax - 1; + while (ws >= 1 && state.pending.charCodeAt(ws - 1) === 32) ws--; + state.pending = state.pending.slice(0, ws); + state.push("hardbreak", "br", 0); + } else { + state.pending = state.pending.slice(0, -1); + state.push("softbreak", "br", 0); + } + else state.push("softbreak", "br", 0); + pos++; + while (pos < max && isSpace(state.src.charCodeAt(pos))) pos++; + state.pos = pos; + return true; +} +const ESCAPED = []; +for (let i = 0; i < 256; i++) ESCAPED.push(0); +"\\!\"#$%&'()*+,./:;<=>?@[]^_`{|}~-".split("").forEach(function (ch) { + ESCAPED[ch.charCodeAt(0)] = 1; +}); +function escape(state, silent) { + let pos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(pos) !== 92) return false; + pos++; + if (pos >= max) return false; + let ch1 = state.src.charCodeAt(pos); + if (ch1 === 10) { + if (!silent) state.push("hardbreak", "br", 0); + pos++; + while (pos < max) { + ch1 = state.src.charCodeAt(pos); + if (!isSpace(ch1)) break; + pos++; + } + state.pos = pos; + return true; + } + let escapedStr = state.src[pos]; + if (ch1 >= 55296 && ch1 <= 56319 && pos + 1 < max) { + const ch2 = state.src.charCodeAt(pos + 1); + if (ch2 >= 56320 && ch2 <= 57343) { + escapedStr += state.src[pos + 1]; + pos++; + } + } + const origStr = "\\" + escapedStr; + if (!silent) { + const token = state.push("text_special", "", 0); + if (ch1 < 256 && ESCAPED[ch1] !== 0) token.content = escapedStr; + else token.content = origStr; + token.markup = origStr; + token.info = "escape"; + } + state.pos = pos + 1; + return true; +} +function backtick(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 96) return false; + const start = pos; + pos++; + const max = state.posMax; + while (pos < max && state.src.charCodeAt(pos) === 96) pos++; + const marker = state.src.slice(start, pos); + const openerLength = marker.length; + if (state.backticksScanned && (state.backticks[openerLength] || 0) <= start) { + if (!silent) state.pending += marker; + state.pos += openerLength; + return true; + } + let matchEnd = pos; + let matchStart; + while ((matchStart = state.src.indexOf("`", matchEnd)) !== -1) { + matchEnd = matchStart + 1; + while (matchEnd < max && state.src.charCodeAt(matchEnd) === 96) matchEnd++; + const closerLength = matchEnd - matchStart; + if (closerLength === openerLength) { + if (!silent) { + const token = state.push("code_inline", "code", 0); + token.markup = marker; + token.content = state.src + .slice(pos, matchStart) + .replace(/\n/g, " ") + .replace(/^ (.+) $/, "$1"); + } + state.pos = matchEnd; + return true; + } + state.backticks[closerLength] = matchStart; + } + state.backticksScanned = true; + if (!silent) state.pending += marker; + state.pos += openerLength; + return true; +} +function strikethrough_tokenize(state, silent) { + const start = state.pos; + const marker = state.src.charCodeAt(start); + if (silent) return false; + if (marker !== 126) return false; + const scanned = state.scanDelims(state.pos, true); + let len = scanned.length; + const ch = String.fromCharCode(marker); + if (len < 2) return false; + let token; + if (len % 2) { + token = state.push("text", "", 0); + token.content = ch; + len--; + } + for (let i = 0; i < len; i += 2) { + token = state.push("text", "", 0); + token.content = ch + ch; + state.delimiters.push({ + marker, + length: 0, + token: state.tokens.length - 1, + end: -1, + open: scanned.can_open, + close: scanned.can_close, + }); + } + state.pos += scanned.length; + return true; +} +function postProcess$1(state, delimiters) { + let token; + const loneMarkers = []; + const max = delimiters.length; + for (let i = 0; i < max; i++) { + const startDelim = delimiters[i]; + if (startDelim.marker !== 126) continue; + if (startDelim.end === -1) continue; + const endDelim = delimiters[startDelim.end]; + token = state.tokens[startDelim.token]; + token.type = "s_open"; + token.tag = "s"; + token.nesting = 1; + token.markup = "~~"; + token.content = ""; + token = state.tokens[endDelim.token]; + token.type = "s_close"; + token.tag = "s"; + token.nesting = -1; + token.markup = "~~"; + token.content = ""; + if ( + state.tokens[endDelim.token - 1].type === "text" && + state.tokens[endDelim.token - 1].content === "~" + ) + loneMarkers.push(endDelim.token - 1); + } + while (loneMarkers.length) { + const i = loneMarkers.pop(); + let j = i + 1; + while (j < state.tokens.length && state.tokens[j].type === "s_close") j++; + j--; + if (i !== j) { + token = state.tokens[j]; + state.tokens[j] = state.tokens[i]; + state.tokens[i] = token; + } + } +} +function strikethrough_postProcess(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + postProcess$1(state, state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + postProcess$1(state, tokens_meta[curr].delimiters); +} +var strikethrough_default = { + tokenize: strikethrough_tokenize, + postProcess: strikethrough_postProcess, +}; +function emphasis_tokenize(state, silent) { + const start = state.pos; + const marker = state.src.charCodeAt(start); + if (silent) return false; + if (marker !== 95 && marker !== 42) return false; + const scanned = state.scanDelims(state.pos, marker === 42); + for (let i = 0; i < scanned.length; i++) { + const token = state.push("text", "", 0); + token.content = String.fromCharCode(marker); + state.delimiters.push({ + marker, + length: scanned.length, + token: state.tokens.length - 1, + end: -1, + open: scanned.can_open, + close: scanned.can_close, + }); + } + state.pos += scanned.length; + return true; +} +function postProcess(state, delimiters) { + const max = delimiters.length; + for (let i = max - 1; i >= 0; i--) { + const startDelim = delimiters[i]; + if (startDelim.marker !== 95 && startDelim.marker !== 42) continue; + if (startDelim.end === -1) continue; + const endDelim = delimiters[startDelim.end]; + const isStrong = + i > 0 && + delimiters[i - 1].end === startDelim.end + 1 && + delimiters[i - 1].marker === startDelim.marker && + delimiters[i - 1].token === startDelim.token - 1 && + delimiters[startDelim.end + 1].token === endDelim.token + 1; + const ch = String.fromCharCode(startDelim.marker); + const token_o = state.tokens[startDelim.token]; + token_o.type = isStrong ? "strong_open" : "em_open"; + token_o.tag = isStrong ? "strong" : "em"; + token_o.nesting = 1; + token_o.markup = isStrong ? ch + ch : ch; + token_o.content = ""; + const token_c = state.tokens[endDelim.token]; + token_c.type = isStrong ? "strong_close" : "em_close"; + token_c.tag = isStrong ? "strong" : "em"; + token_c.nesting = -1; + token_c.markup = isStrong ? ch + ch : ch; + token_c.content = ""; + if (isStrong) { + state.tokens[delimiters[i - 1].token].content = ""; + state.tokens[delimiters[startDelim.end + 1].token].content = ""; + i--; + } + } +} +function emphasis_post_process(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + postProcess(state, state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + postProcess(state, tokens_meta[curr].delimiters); +} +var emphasis_default = { + tokenize: emphasis_tokenize, + postProcess: emphasis_post_process, +}; +function link(state, silent) { + let code, label, res, ref; + let href = ""; + let title = ""; + let start = state.pos; + let parseReference = true; + if (state.src.charCodeAt(state.pos) !== 91) return false; + const oldPos = state.pos; + const max = state.posMax; + const labelStart = state.pos + 1; + const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true); + if (labelEnd < 0) return false; + let pos = labelEnd + 1; + if (pos < max && state.src.charCodeAt(pos) === 40) { + parseReference = false; + pos++; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + if (pos >= max) return false; + start = pos; + res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); + if (res.ok) { + href = state.md.normalizeLink(res.str); + if (state.md.validateLink(href)) pos = res.pos; + else href = ""; + start = pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); + if (pos < max && start !== pos && res.ok) { + title = res.str; + pos = res.pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + } + } + if (pos >= max || state.src.charCodeAt(pos) !== 41) parseReference = true; + pos++; + } + if (parseReference) { + if (typeof state.env.references === "undefined") return false; + if (pos < max && state.src.charCodeAt(pos) === 91) { + start = pos + 1; + pos = state.md.helpers.parseLinkLabel(state, pos); + if (pos >= 0) label = state.src.slice(start, pos++); + else pos = labelEnd + 1; + } else pos = labelEnd + 1; + if (!label) label = state.src.slice(labelStart, labelEnd); + ref = state.env.references[normalizeReference(label)]; + if (!ref) { + state.pos = oldPos; + return false; + } + href = ref.href; + title = ref.title; + } + if (!silent) { + state.pos = labelStart; + state.posMax = labelEnd; + const token_o = state.push("link_open", "a", 1); + const attrs = [["href", href]]; + token_o.attrs = attrs; + if (title) attrs.push(["title", title]); + state.linkLevel++; + state.md.inline.tokenize(state); + state.linkLevel--; + state.push("link_close", "a", -1); + } + state.pos = pos; + state.posMax = max; + return true; +} +function image(state, silent) { + let code, content, label, pos, ref, res, title, start; + let href = ""; + const oldPos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(state.pos) !== 33) return false; + if (state.src.charCodeAt(state.pos + 1) !== 91) return false; + const labelStart = state.pos + 2; + const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false); + if (labelEnd < 0) return false; + pos = labelEnd + 1; + if (pos < max && state.src.charCodeAt(pos) === 40) { + pos++; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + if (pos >= max) return false; + start = pos; + res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); + if (res.ok) { + href = state.md.normalizeLink(res.str); + if (state.md.validateLink(href)) pos = res.pos; + else href = ""; + } + start = pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); + if (pos < max && start !== pos && res.ok) { + title = res.str; + pos = res.pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + } else title = ""; + if (pos >= max || state.src.charCodeAt(pos) !== 41) { + state.pos = oldPos; + return false; + } + pos++; + } else { + if (typeof state.env.references === "undefined") return false; + if (pos < max && state.src.charCodeAt(pos) === 91) { + start = pos + 1; + pos = state.md.helpers.parseLinkLabel(state, pos); + if (pos >= 0) label = state.src.slice(start, pos++); + else pos = labelEnd + 1; + } else pos = labelEnd + 1; + if (!label) label = state.src.slice(labelStart, labelEnd); + ref = state.env.references[normalizeReference(label)]; + if (!ref) { + state.pos = oldPos; + return false; + } + href = ref.href; + title = ref.title; + } + if (!silent) { + content = state.src.slice(labelStart, labelEnd); + const tokens = []; + state.md.inline.parse(content, state.md, state.env, tokens); + const token = state.push("image", "img", 0); + const attrs = [ + ["src", href], + ["alt", ""], + ]; + token.attrs = attrs; + token.children = tokens; + token.content = content; + if (title) attrs.push(["title", title]); + } + state.pos = pos; + state.posMax = max; + return true; +} +const EMAIL_RE = + /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/; +const AUTOLINK_RE = /^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/; +function autolink(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 60) return false; + const start = state.pos; + const max = state.posMax; + for (;;) { + if (++pos >= max) return false; + const ch = state.src.charCodeAt(pos); + if (ch === 60) return false; + if (ch === 62) break; + } + const url = state.src.slice(start + 1, pos); + if (AUTOLINK_RE.test(url)) { + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "autolink"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "autolink"; + token_c.info = "auto"; + } + state.pos += url.length + 2; + return true; + } + if (EMAIL_RE.test(url)) { + const fullUrl = state.md.normalizeLink("mailto:" + url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "autolink"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "autolink"; + token_c.info = "auto"; + } + state.pos += url.length + 2; + return true; + } + return false; +} +function isLinkOpen(str) { + return /^\s]/i.test(str); +} +function isLinkClose(str) { + return /^<\/a\s*>/i.test(str); +} +function isLetter(ch) { + const lc = ch | 32; + return lc >= 97 && lc <= 122; +} +function html_inline(state, silent) { + if (!state.md.options.html) return false; + const max = state.posMax; + const pos = state.pos; + if (state.src.charCodeAt(pos) !== 60 || pos + 2 >= max) return false; + const ch = state.src.charCodeAt(pos + 1); + if (ch !== 33 && ch !== 63 && ch !== 47 && !isLetter(ch)) return false; + const match = state.src.slice(pos).match(HTML_TAG_RE); + if (!match) return false; + if (!silent) { + const token = state.push("html_inline", "", 0); + token.content = match[0]; + if (isLinkOpen(token.content)) state.linkLevel++; + if (isLinkClose(token.content)) state.linkLevel--; + } + state.pos += match[0].length; + return true; +} +const DIGITAL_RE = /^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i; +const NAMED_RE = /^&([a-z][a-z0-9]{1,31});/i; +function entity(state, silent) { + const pos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(pos) !== 38) return false; + if (pos + 1 >= max) return false; + if (state.src.charCodeAt(pos + 1) === 35) { + const match = state.src.slice(pos).match(DIGITAL_RE); + if (match) { + if (!silent) { + const code = + match[1][0].toLowerCase() === "x" + ? parseInt(match[1].slice(1), 16) + : parseInt(match[1], 10); + const token = state.push("text_special", "", 0); + token.content = isValidEntityCode(code) ? fromCodePoint(code) : fromCodePoint(65533); + token.markup = match[0]; + token.info = "entity"; + } + state.pos += match[0].length; + return true; + } + } else { + const match = state.src.slice(pos).match(NAMED_RE); + if (match) { + const decoded = decodeHTML(match[0]); + if (decoded !== match[0]) { + if (!silent) { + const token = state.push("text_special", "", 0); + token.content = decoded; + token.markup = match[0]; + token.info = "entity"; + } + state.pos += match[0].length; + return true; + } + } + } + return false; +} +function processDelimiters(delimiters) { + const openersBottom = {}; + const max = delimiters.length; + if (!max) return; + let headerIdx = 0; + let lastTokenIdx = -2; + const jumps = []; + for (let closerIdx = 0; closerIdx < max; closerIdx++) { + const closer = delimiters[closerIdx]; + jumps.push(0); + if (delimiters[headerIdx].marker !== closer.marker || lastTokenIdx !== closer.token - 1) + headerIdx = closerIdx; + lastTokenIdx = closer.token; + closer.length = closer.length || 0; + if (!closer.close) continue; + if (!openersBottom.hasOwnProperty(closer.marker)) + openersBottom[closer.marker] = [-1, -1, -1, -1, -1, -1]; + const minOpenerIdx = openersBottom[closer.marker][(closer.open ? 3 : 0) + (closer.length % 3)]; + let openerIdx = headerIdx - jumps[headerIdx] - 1; + let newMinOpenerIdx = openerIdx; + for (; openerIdx > minOpenerIdx; openerIdx -= jumps[openerIdx] + 1) { + const opener = delimiters[openerIdx]; + if (opener.marker !== closer.marker) continue; + if (opener.open && opener.end < 0) { + let isOddMatch = false; + if (opener.close || closer.open) { + if ((opener.length + closer.length) % 3 === 0) { + if (opener.length % 3 !== 0 || closer.length % 3 !== 0) isOddMatch = true; + } + } + if (!isOddMatch) { + const lastJump = + openerIdx > 0 && !delimiters[openerIdx - 1].open ? jumps[openerIdx - 1] + 1 : 0; + jumps[closerIdx] = closerIdx - openerIdx + lastJump; + jumps[openerIdx] = lastJump; + closer.open = false; + opener.end = closerIdx; + opener.close = false; + newMinOpenerIdx = -1; + lastTokenIdx = -2; + break; + } + } + } + if (newMinOpenerIdx !== -1) + openersBottom[closer.marker][(closer.open ? 3 : 0) + ((closer.length || 0) % 3)] = + newMinOpenerIdx; + } +} +function link_pairs(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + processDelimiters(state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + processDelimiters(tokens_meta[curr].delimiters); +} +function fragments_join(state) { + let curr, last; + let level = 0; + const tokens = state.tokens; + const max = state.tokens.length; + for (curr = last = 0; curr < max; curr++) { + if (tokens[curr].nesting < 0) level--; + tokens[curr].level = level; + if (tokens[curr].nesting > 0) level++; + if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") + tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; + else { + if (curr !== last) tokens[last] = tokens[curr]; + last++; + } + } + if (curr !== last) tokens.length = last; +} +/** internal + * class ParserInline + * + * Tokenizes paragraph content. + **/ +const _rules = [ + ["text", text], + ["linkify", linkify], + ["newline", newline], + ["escape", escape], + ["backticks", backtick], + ["strikethrough", strikethrough_default.tokenize], + ["emphasis", emphasis_default.tokenize], + ["link", link], + ["image", image], + ["autolink", autolink], + ["html_inline", html_inline], + ["entity", entity], +]; +const _rules2 = [ + ["balance_pairs", link_pairs], + ["strikethrough", strikethrough_default.postProcess], + ["emphasis", emphasis_default.postProcess], + ["fragments_join", fragments_join], +]; +/** + * new ParserInline() + **/ +function ParserInline() { + /** + * ParserInline#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of inline rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules.length; i++) this.ruler.push(_rules[i][0], _rules[i][1]); + /** + * ParserInline#ruler2 -> Ruler + * + * [[Ruler]] instance. Second ruler used for post-processing + * (e.g. in emphasis-like rules). + **/ + this.ruler2 = new Ruler(); + for (let i = 0; i < _rules2.length; i++) this.ruler2.push(_rules2[i][0], _rules2[i][1]); +} +ParserInline.prototype.skipToken = function (state) { + const pos = state.pos; + const rules = this.ruler.getRules(""); + const len = rules.length; + const maxNesting = state.md.options.maxNesting; + const cache = state.cache; + if (typeof cache[pos] !== "undefined") { + state.pos = cache[pos]; + return; + } + let ok = false; + if (state.level < maxNesting) + for (let i = 0; i < len; i++) { + state.level++; + ok = rules[i](state, true); + state.level--; + if (ok) { + if (pos >= state.pos) throw new Error("inline rule didn't increment state.pos"); + break; + } + } + else state.pos = state.posMax; + if (!ok) state.pos++; + cache[pos] = state.pos; +}; +ParserInline.prototype.tokenize = function (state) { + const rules = this.ruler.getRules(""); + const len = rules.length; + const end = state.posMax; + const maxNesting = state.md.options.maxNesting; + while (state.pos < end) { + const prevPos = state.pos; + let ok = false; + if (state.level < maxNesting) + for (let i = 0; i < len; i++) { + ok = rules[i](state, false); + if (ok) { + if (prevPos >= state.pos) throw new Error("inline rule didn't increment state.pos"); + break; + } + } + if (ok) { + if (state.pos >= end) break; + continue; + } + state.pending += state.src[state.pos++]; + } + if (state.pending) state.pushPending(); +}; +/** + * ParserInline.parse(str, md, env, outTokens) + * + * Process input string and push inline tokens into `outTokens` + **/ +ParserInline.prototype.parse = function (str, md, env, outTokens) { + const state = new this.State(str, md, env, outTokens); + this.tokenize(state); + const rules = this.ruler2.getRules(""); + const len = rules.length; + for (let i = 0; i < len; i++) rules[i](state); +}; +ParserInline.prototype.State = StateInline; +function re_default(opts) { + const re = {}; + opts = opts || {}; + re.src_Any = regex_default$5.source; + re.src_Cc = regex_default$4.source; + re.src_Z = regex_default.source; + re.src_P = regex_default$2.source; + re.src_ZPCc = [re.src_Z, re.src_P, re.src_Cc].join("|"); + re.src_ZCc = [re.src_Z, re.src_Cc].join("|"); + const text_separators = "[><|]"; + re.src_pseudo_letter = "(?:(?!" + text_separators + "|" + re.src_ZPCc + ")" + re.src_Any + ")"; + re.src_ip4 = + "(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"; + re.src_auth = "(?:(?:(?!" + re.src_ZCc + "|[@/\\[\\]()]).)+@)?"; + re.src_port = "(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?"; + re.src_host_terminator = + "(?=$|" + + text_separators + + "|" + + re.src_ZPCc + + ")(?!" + + (opts["---"] ? "-(?!--)|" : "-|") + + "_|:\\d|\\.-|\\.(?!$|" + + re.src_ZPCc + + "))"; + re.src_path = + "(?:[/?#](?:(?!" + + re.src_ZCc + + "|[><|]|[()[\\]{}.,\"'?!\\-;]).|\\[(?:(?!" + + re.src_ZCc + + "|\\]).)*\\]|\\((?:(?!" + + re.src_ZCc + + "|[)]).)*\\)|\\{(?:(?!" + + re.src_ZCc + + '|[}]).)*\\}|\\"(?:(?!' + + re.src_ZCc + + '|["]).)+\\"|\\\'(?:(?!' + + re.src_ZCc + + "|[']).)+\\'|\\'(?=" + + re.src_pseudo_letter + + "|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!" + + re.src_ZCc + + "|[.]|$)|" + + (opts["---"] ? "\\-(?!--(?:[^-]|$))(?:-*)|" : "\\-+|") + + ",(?!" + + re.src_ZCc + + "|$)|;(?!" + + re.src_ZCc + + "|$)|\\!+(?!" + + re.src_ZCc + + "|[!]|$)|\\?(?!" + + re.src_ZCc + + "|[?]|$))+|\\/)?"; + re.src_email_name = '[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*'; + re.src_xn = "xn--[a-z0-9\\-]{1,59}"; + re.src_domain_root = "(?:" + re.src_xn + "|" + re.src_pseudo_letter + "{1,63})"; + re.src_domain = + "(?:" + + re.src_xn + + "|(?:" + + re.src_pseudo_letter + + ")|(?:" + + re.src_pseudo_letter + + "(?:-|" + + re.src_pseudo_letter + + "){0,61}" + + re.src_pseudo_letter + + "))"; + re.src_host = "(?:(?:(?:(?:" + re.src_domain + ")\\.)*" + re.src_domain + "))"; + re.tpl_host_fuzzy = "(?:" + re.src_ip4 + "|(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%)))"; + re.tpl_host_no_ip_fuzzy = "(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%))"; + re.src_host_strict = re.src_host + re.src_host_terminator; + re.tpl_host_fuzzy_strict = re.tpl_host_fuzzy + re.src_host_terminator; + re.src_host_port_strict = re.src_host + re.src_port + re.src_host_terminator; + re.tpl_host_port_fuzzy_strict = re.tpl_host_fuzzy + re.src_port + re.src_host_terminator; + re.tpl_host_port_no_ip_fuzzy_strict = + re.tpl_host_no_ip_fuzzy + re.src_port + re.src_host_terminator; + re.tpl_host_fuzzy_test = + "localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:" + re.src_ZPCc + "|>|$))"; + re.tpl_email_fuzzy = + "(^|" + + text_separators + + '|"|\\(|' + + re.src_ZCc + + ")(" + + re.src_email_name + + "@" + + re.tpl_host_fuzzy_strict + + ")"; + re.tpl_link_fuzzy = + "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + + re.src_ZPCc + + "))((?![$+<=>^`||])" + + re.tpl_host_port_fuzzy_strict + + re.src_path + + ")"; + re.tpl_link_no_ip_fuzzy = + "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + + re.src_ZPCc + + "))((?![$+<=>^`||])" + + re.tpl_host_port_no_ip_fuzzy_strict + + re.src_path + + ")"; + return re; +} +function assign(obj) { + Array.prototype.slice.call(arguments, 1).forEach(function (source) { + if (!source) return; + Object.keys(source).forEach(function (key) { + obj[key] = source[key]; + }); + }); + return obj; +} +function _class(obj) { + return Object.prototype.toString.call(obj); +} +function isString(obj) { + return _class(obj) === "[object String]"; +} +function isObject(obj) { + return _class(obj) === "[object Object]"; +} +function isRegExp(obj) { + return _class(obj) === "[object RegExp]"; +} +function isFunction(obj) { + return _class(obj) === "[object Function]"; +} +function escapeRE(str) { + return str.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); +} +const defaultOptions = { + fuzzyLink: true, + fuzzyEmail: true, + fuzzyIP: false, +}; +function isOptionsObj(obj) { + return Object.keys(obj || {}).reduce(function (acc, k) { + return acc || defaultOptions.hasOwnProperty(k); + }, false); +} +const defaultSchemas = { + "http:": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.http) + self.re.http = new RegExp( + "^\\/\\/" + self.re.src_auth + self.re.src_host_port_strict + self.re.src_path, + "i", + ); + if (self.re.http.test(tail)) return tail.match(self.re.http)[0].length; + return 0; + }, + }, + "https:": "http:", + "ftp:": "http:", + "//": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.no_http) + self.re.no_http = new RegExp( + "^" + + self.re.src_auth + + "(?:localhost|(?:(?:" + + self.re.src_domain + + ")\\.)+" + + self.re.src_domain_root + + ")" + + self.re.src_port + + self.re.src_host_terminator + + self.re.src_path, + "i", + ); + if (self.re.no_http.test(tail)) { + if (pos >= 3 && text[pos - 3] === ":") return 0; + if (pos >= 3 && text[pos - 3] === "/") return 0; + return tail.match(self.re.no_http)[0].length; + } + return 0; + }, + }, + "mailto:": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.mailto) + self.re.mailto = new RegExp( + "^" + self.re.src_email_name + "@" + self.re.src_host_strict, + "i", + ); + if (self.re.mailto.test(tail)) return tail.match(self.re.mailto)[0].length; + return 0; + }, + }, +}; +const tlds_2ch_src_re = + "a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]"; +const tlds_default = + "biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф".split("|"); +function resetScanCache(self) { + self.__index__ = -1; + self.__text_cache__ = ""; +} +function createValidator(re) { + return function (text, pos) { + const tail = text.slice(pos); + if (re.test(tail)) return tail.match(re)[0].length; + return 0; + }; +} +function createNormalizer() { + return function (match, self) { + self.normalize(match); + }; +} +function compile(self) { + const re = (self.re = re_default(self.__opts__)); + const tlds = self.__tlds__.slice(); + self.onCompile(); + if (!self.__tlds_replaced__) tlds.push(tlds_2ch_src_re); + tlds.push(re.src_xn); + re.src_tlds = tlds.join("|"); + function untpl(tpl) { + return tpl.replace("%TLDS%", re.src_tlds); + } + re.email_fuzzy = RegExp(untpl(re.tpl_email_fuzzy), "i"); + re.link_fuzzy = RegExp(untpl(re.tpl_link_fuzzy), "i"); + re.link_no_ip_fuzzy = RegExp(untpl(re.tpl_link_no_ip_fuzzy), "i"); + re.host_fuzzy_test = RegExp(untpl(re.tpl_host_fuzzy_test), "i"); + const aliases = []; + self.__compiled__ = {}; + function schemaError(name, val) { + throw new Error('(LinkifyIt) Invalid schema "' + name + '": ' + val); + } + Object.keys(self.__schemas__).forEach(function (name) { + const val = self.__schemas__[name]; + if (val === null) return; + const compiled = { + validate: null, + link: null, + }; + self.__compiled__[name] = compiled; + if (isObject(val)) { + if (isRegExp(val.validate)) compiled.validate = createValidator(val.validate); + else if (isFunction(val.validate)) compiled.validate = val.validate; + else schemaError(name, val); + if (isFunction(val.normalize)) compiled.normalize = val.normalize; + else if (!val.normalize) compiled.normalize = createNormalizer(); + else schemaError(name, val); + return; + } + if (isString(val)) { + aliases.push(name); + return; + } + schemaError(name, val); + }); + aliases.forEach(function (alias) { + if (!self.__compiled__[self.__schemas__[alias]]) return; + self.__compiled__[alias].validate = self.__compiled__[self.__schemas__[alias]].validate; + self.__compiled__[alias].normalize = self.__compiled__[self.__schemas__[alias]].normalize; + }); + self.__compiled__[""] = { + validate: null, + normalize: createNormalizer(), + }; + const slist = Object.keys(self.__compiled__) + .filter(function (name) { + return name.length > 0 && self.__compiled__[name]; + }) + .map(escapeRE) + .join("|"); + self.re.schema_test = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "i"); + self.re.schema_search = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "ig"); + self.re.schema_at_start = RegExp("^" + self.re.schema_search.source, "i"); + self.re.pretest = RegExp( + "(" + self.re.schema_test.source + ")|(" + self.re.host_fuzzy_test.source + ")|@", + "i", + ); + resetScanCache(self); +} +/** + * class Match + * + * Match result. Single element of array, returned by [[LinkifyIt#match]] + **/ +function Match(self, shift) { + const start = self.__index__; + const end = self.__last_index__; + const text = self.__text_cache__.slice(start, end); + /** + * Match#schema -> String + * + * Prefix (protocol) for matched string. + **/ + this.schema = self.__schema__.toLowerCase(); + /** + * Match#index -> Number + * + * First position of matched string. + **/ + this.index = start + shift; + /** + * Match#lastIndex -> Number + * + * Next position after matched string. + **/ + this.lastIndex = end + shift; + /** + * Match#raw -> String + * + * Matched string. + **/ + this.raw = text; + /** + * Match#text -> String + * + * Notmalized text of matched string. + **/ + this.text = text; + /** + * Match#url -> String + * + * Normalized url of matched string. + **/ + this.url = text; +} +function createMatch(self, shift) { + const match = new Match(self, shift); + self.__compiled__[match.schema].normalize(match, self); + return match; +} +/** + * class LinkifyIt + **/ +/** + * new LinkifyIt(schemas, options) + * - schemas (Object): Optional. Additional schemas to validate (prefix/validator) + * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } + * + * Creates new linkifier instance with optional additional schemas. + * Can be called without `new` keyword for convenience. + * + * By default understands: + * + * - `http(s)://...` , `ftp://...`, `mailto:...` & `//...` links + * - "fuzzy" links and emails (example.com, foo@bar.com). + * + * `schemas` is an object, where each key/value describes protocol/rule: + * + * - __key__ - link prefix (usually, protocol name with `:` at the end, `skype:` + * for example). `linkify-it` makes shure that prefix is not preceeded with + * alphanumeric char and symbols. Only whitespaces and punctuation allowed. + * - __value__ - rule to check tail after link prefix + * - _String_ - just alias to existing rule + * - _Object_ + * - _validate_ - validator function (should return matched length on success), + * or `RegExp`. + * - _normalize_ - optional function to normalize text & url of matched result + * (for example, for @twitter mentions). + * + * `options`: + * + * - __fuzzyLink__ - recognige URL-s without `http(s):` prefix. Default `true`. + * - __fuzzyIP__ - allow IPs in fuzzy links above. Can conflict with some texts + * like version numbers. Default `false`. + * - __fuzzyEmail__ - recognize emails without `mailto:` prefix. + * + **/ +function LinkifyIt(schemas, options) { + if (!(this instanceof LinkifyIt)) return new LinkifyIt(schemas, options); + if (!options) { + if (isOptionsObj(schemas)) { + options = schemas; + schemas = {}; + } + } + this.__opts__ = assign({}, defaultOptions, options); + this.__index__ = -1; + this.__last_index__ = -1; + this.__schema__ = ""; + this.__text_cache__ = ""; + this.__schemas__ = assign({}, defaultSchemas, schemas); + this.__compiled__ = {}; + this.__tlds__ = tlds_default; + this.__tlds_replaced__ = false; + this.re = {}; + compile(this); +} +/** chainable + * LinkifyIt#add(schema, definition) + * - schema (String): rule name (fixed pattern prefix) + * - definition (String|RegExp|Object): schema definition + * + * Add new rule definition. See constructor description for details. + **/ +LinkifyIt.prototype.add = function add(schema, definition) { + this.__schemas__[schema] = definition; + compile(this); + return this; +}; +/** chainable + * LinkifyIt#set(options) + * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } + * + * Set recognition options for links without schema. + **/ +LinkifyIt.prototype.set = function set(options) { + this.__opts__ = assign(this.__opts__, options); + return this; +}; +/** + * LinkifyIt#test(text) -> Boolean + * + * Searches linkifiable pattern and returns `true` on success or `false` on fail. + **/ +LinkifyIt.prototype.test = function test(text) { + this.__text_cache__ = text; + this.__index__ = -1; + if (!text.length) return false; + let m, ml, me, len, shift, next, re, tld_pos, at_pos; + if (this.re.schema_test.test(text)) { + re = this.re.schema_search; + re.lastIndex = 0; + while ((m = re.exec(text)) !== null) { + len = this.testSchemaAt(text, m[2], re.lastIndex); + if (len) { + this.__schema__ = m[2]; + this.__index__ = m.index + m[1].length; + this.__last_index__ = m.index + m[0].length + len; + break; + } + } + } + if (this.__opts__.fuzzyLink && this.__compiled__["http:"]) { + tld_pos = text.search(this.re.host_fuzzy_test); + if (tld_pos >= 0) { + if (this.__index__ < 0 || tld_pos < this.__index__) { + if ( + (ml = text.match( + this.__opts__.fuzzyIP ? this.re.link_fuzzy : this.re.link_no_ip_fuzzy, + )) !== null + ) { + shift = ml.index + ml[1].length; + if (this.__index__ < 0 || shift < this.__index__) { + this.__schema__ = ""; + this.__index__ = shift; + this.__last_index__ = ml.index + ml[0].length; + } + } + } + } + } + if (this.__opts__.fuzzyEmail && this.__compiled__["mailto:"]) { + at_pos = text.indexOf("@"); + if (at_pos >= 0) { + if ((me = text.match(this.re.email_fuzzy)) !== null) { + shift = me.index + me[1].length; + next = me.index + me[0].length; + if ( + this.__index__ < 0 || + shift < this.__index__ || + (shift === this.__index__ && next > this.__last_index__) + ) { + this.__schema__ = "mailto:"; + this.__index__ = shift; + this.__last_index__ = next; + } + } + } + } + return this.__index__ >= 0; +}; +/** + * LinkifyIt#pretest(text) -> Boolean + * + * Very quick check, that can give false positives. Returns true if link MAY BE + * can exists. Can be used for speed optimization, when you need to check that + * link NOT exists. + **/ +LinkifyIt.prototype.pretest = function pretest(text) { + return this.re.pretest.test(text); +}; +/** + * LinkifyIt#testSchemaAt(text, name, position) -> Number + * - text (String): text to scan + * - name (String): rule (schema) name + * - position (Number): text offset to check from + * + * Similar to [[LinkifyIt#test]] but checks only specific protocol tail exactly + * at given position. Returns length of found pattern (0 on fail). + **/ +LinkifyIt.prototype.testSchemaAt = function testSchemaAt(text, schema, pos) { + if (!this.__compiled__[schema.toLowerCase()]) return 0; + return this.__compiled__[schema.toLowerCase()].validate(text, pos, this); +}; +/** + * LinkifyIt#match(text) -> Array|null + * + * Returns array of found link descriptions or `null` on fail. We strongly + * recommend to use [[LinkifyIt#test]] first, for best speed. + * + * ##### Result match description + * + * - __schema__ - link schema, can be empty for fuzzy links, or `//` for + * protocol-neutral links. + * - __index__ - offset of matched text + * - __lastIndex__ - index of next char after mathch end + * - __raw__ - matched text + * - __text__ - normalized text + * - __url__ - link, generated from matched text + **/ +LinkifyIt.prototype.match = function match(text) { + const result = []; + let shift = 0; + if (this.__index__ >= 0 && this.__text_cache__ === text) { + result.push(createMatch(this, shift)); + shift = this.__last_index__; + } + let tail = shift ? text.slice(shift) : text; + while (this.test(tail)) { + result.push(createMatch(this, shift)); + tail = tail.slice(this.__last_index__); + shift += this.__last_index__; + } + if (result.length) return result; + return null; +}; +/** + * LinkifyIt#matchAtStart(text) -> Match|null + * + * Returns fully-formed (not fuzzy) link if it starts at the beginning + * of the string, and null otherwise. + **/ +LinkifyIt.prototype.matchAtStart = function matchAtStart(text) { + this.__text_cache__ = text; + this.__index__ = -1; + if (!text.length) return null; + const m = this.re.schema_at_start.exec(text); + if (!m) return null; + const len = this.testSchemaAt(text, m[2], m[0].length); + if (!len) return null; + this.__schema__ = m[2]; + this.__index__ = m.index + m[1].length; + this.__last_index__ = m.index + m[0].length + len; + return createMatch(this, 0); +}; +/** chainable + * LinkifyIt#tlds(list [, keepOld]) -> this + * - list (Array): list of tlds + * - keepOld (Boolean): merge with current list if `true` (`false` by default) + * + * Load (or merge) new tlds list. Those are user for fuzzy links (without prefix) + * to avoid false positives. By default this algorythm used: + * + * - hostname with any 2-letter root zones are ok. + * - biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф + * are ok. + * - encoded (`xn--...`) root zones are ok. + * + * If list is replaced, then exact match for 2-chars root zones will be checked. + **/ +LinkifyIt.prototype.tlds = function tlds(list, keepOld) { + list = Array.isArray(list) ? list : [list]; + if (!keepOld) { + this.__tlds__ = list.slice(); + this.__tlds_replaced__ = true; + compile(this); + return this; + } + this.__tlds__ = this.__tlds__ + .concat(list) + .sort() + .filter(function (el, idx, arr) { + return el !== arr[idx - 1]; + }) + .reverse(); + compile(this); + return this; +}; +/** + * LinkifyIt#normalize(match) + * + * Default normalizer (if schema does not define it's own). + **/ +LinkifyIt.prototype.normalize = function normalize(match) { + if (!match.schema) match.url = "http://" + match.url; + if (match.schema === "mailto:" && !/^mailto:/i.test(match.url)) match.url = "mailto:" + match.url; +}; +/** + * LinkifyIt#onCompile() + * + * Override to modify basic RegExp-s. + **/ +LinkifyIt.prototype.onCompile = function onCompile() {}; +/** Highest positive signed 32-bit float value */ +const maxInt = 2147483647; +/** Bootstring parameters */ +const base = 36; +const tMin = 1; +const tMax = 26; +const skew = 38; +const damp = 700; +const initialBias = 72; +const initialN = 128; +const delimiter = "-"; +/** Regular expressions */ +const regexPunycode = /^xn--/; +const regexNonASCII = /[^\0-\x7F]/; +const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; +/** Error messages */ +const errors = { + overflow: "Overflow: input needs wider integers to process", + "not-basic": "Illegal input >= 0x80 (not a basic code point)", + "invalid-input": "Invalid input", +}; +/** Convenience shortcuts */ +const baseMinusTMin = base - tMin; +const floor = Math.floor; +const stringFromCharCode = String.fromCharCode; +/** + * A generic error utility function. + * @private + * @param {String} type The error type. + * @returns {Error} Throws a `RangeError` with the applicable error message. + */ +function error(type) { + throw new RangeError(errors[type]); +} +/** + * A generic `Array#map` utility function. + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function that gets called for every array + * item. + * @returns {Array} A new array of values returned by the callback function. + */ +function map(array, callback) { + const result = []; + let length = array.length; + while (length--) result[length] = callback(array[length]); + return result; +} +/** + * A simple `Array#map`-like wrapper to work with domain name strings or email + * addresses. + * @private + * @param {String} domain The domain name or email address. + * @param {Function} callback The function that gets called for every + * character. + * @returns {String} A new string of characters returned by the callback + * function. + */ +function mapDomain(domain, callback) { + const parts = domain.split("@"); + let result = ""; + if (parts.length > 1) { + result = parts[0] + "@"; + domain = parts[1]; + } + domain = domain.replace(regexSeparators, "."); + const encoded = map(domain.split("."), callback).join("."); + return result + encoded; +} +/** + * Creates an array containing the numeric code points of each Unicode + * character in the string. While JavaScript uses UCS-2 internally, + * this function will convert a pair of surrogate halves (each of which + * UCS-2 exposes as separate characters) into a single code point, + * matching UTF-16. + * @see `punycode.ucs2.encode` + * @see + * @memberOf punycode.ucs2 + * @name decode + * @param {String} string The Unicode input string (UCS-2). + * @returns {Array} The new array of code points. + */ +function ucs2decode(string) { + const output = []; + let counter = 0; + const length = string.length; + while (counter < length) { + const value = string.charCodeAt(counter++); + if (value >= 55296 && value <= 56319 && counter < length) { + const extra = string.charCodeAt(counter++); + if ((extra & 64512) == 56320) output.push(((value & 1023) << 10) + (extra & 1023) + 65536); + else { + output.push(value); + counter--; + } + } else output.push(value); + } + return output; +} +/** + * Creates a string based on an array of numeric code points. + * @see `punycode.ucs2.decode` + * @memberOf punycode.ucs2 + * @name encode + * @param {Array} codePoints The array of numeric code points. + * @returns {String} The new Unicode string (UCS-2). + */ +const ucs2encode = (codePoints) => String.fromCodePoint(...codePoints); +/** + * Converts a basic code point into a digit/integer. + * @see `digitToBasic()` + * @private + * @param {Number} codePoint The basic numeric code point value. + * @returns {Number} The numeric value of a basic code point (for use in + * representing integers) in the range `0` to `base - 1`, or `base` if + * the code point does not represent a value. + */ +const basicToDigit = function (codePoint) { + if (codePoint >= 48 && codePoint < 58) return 26 + (codePoint - 48); + if (codePoint >= 65 && codePoint < 91) return codePoint - 65; + if (codePoint >= 97 && codePoint < 123) return codePoint - 97; + return base; +}; +/** + * Converts a digit/integer into a basic code point. + * @see `basicToDigit()` + * @private + * @param {Number} digit The numeric value of a basic code point. + * @returns {Number} The basic code point whose value (when used for + * representing integers) is `digit`, which needs to be in the range + * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is + * used; else, the lowercase form is used. The behavior is undefined + * if `flag` is non-zero and `digit` has no uppercase form. + */ +const digitToBasic = function (digit, flag) { + return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); +}; +/** + * Bias adaptation function as per section 3.4 of RFC 3492. + * https://tools.ietf.org/html/rfc3492#section-3.4 + * @private + */ +const adapt = function (delta, numPoints, firstTime) { + let k = 0; + delta = firstTime ? floor(delta / damp) : delta >> 1; + delta += floor(delta / numPoints); + for (; delta > (baseMinusTMin * tMax) >> 1; k += base) delta = floor(delta / baseMinusTMin); + return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)); +}; +/** + * Converts a Punycode string of ASCII-only symbols to a string of Unicode + * symbols. + * @memberOf punycode + * @param {String} input The Punycode string of ASCII-only symbols. + * @returns {String} The resulting string of Unicode symbols. + */ +const decode = function (input) { + const output = []; + const inputLength = input.length; + let i = 0; + let n = initialN; + let bias = initialBias; + let basic = input.lastIndexOf(delimiter); + if (basic < 0) basic = 0; + for (let j = 0; j < basic; ++j) { + if (input.charCodeAt(j) >= 128) error("not-basic"); + output.push(input.charCodeAt(j)); + } + for (let index = basic > 0 ? basic + 1 : 0; index < inputLength; ) { + const oldi = i; + for (let w = 1, k = base; ; k += base) { + if (index >= inputLength) error("invalid-input"); + const digit = basicToDigit(input.charCodeAt(index++)); + if (digit >= base) error("invalid-input"); + if (digit > floor((maxInt - i) / w)) error("overflow"); + i += digit * w; + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (digit < t) break; + const baseMinusT = base - t; + if (w > floor(maxInt / baseMinusT)) error("overflow"); + w *= baseMinusT; + } + const out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + if (floor(i / out) > maxInt - n) error("overflow"); + n += floor(i / out); + i %= out; + output.splice(i++, 0, n); + } + return String.fromCodePoint(...output); +}; +/** + * Converts a string of Unicode symbols (e.g. a domain name label) to a + * Punycode string of ASCII-only symbols. + * @memberOf punycode + * @param {String} input The string of Unicode symbols. + * @returns {String} The resulting Punycode string of ASCII-only symbols. + */ +const encode = function (input) { + const output = []; + input = ucs2decode(input); + const inputLength = input.length; + let n = initialN; + let delta = 0; + let bias = initialBias; + for (const currentValue of input) + if (currentValue < 128) output.push(stringFromCharCode(currentValue)); + const basicLength = output.length; + let handledCPCount = basicLength; + if (basicLength) output.push(delimiter); + while (handledCPCount < inputLength) { + let m = maxInt; + for (const currentValue of input) if (currentValue >= n && currentValue < m) m = currentValue; + const handledCPCountPlusOne = handledCPCount + 1; + if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) error("overflow"); + delta += (m - n) * handledCPCountPlusOne; + n = m; + for (const currentValue of input) { + if (currentValue < n && ++delta > maxInt) error("overflow"); + if (currentValue === n) { + let q = delta; + for (let k = base; ; k += base) { + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (q < t) break; + const qMinusT = q - t; + const baseMinusT = base - t; + output.push(stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0))); + q = floor(qMinusT / baseMinusT); + } + output.push(stringFromCharCode(digitToBasic(q, 0))); + bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength); + delta = 0; + ++handledCPCount; + } + } + ++delta; + ++n; + } + return output.join(""); +}; +/** + * Converts a Punycode string representing a domain name or an email address + * to Unicode. Only the Punycoded parts of the input will be converted, i.e. + * it doesn't matter if you call it on a string that has already been + * converted to Unicode. + * @memberOf punycode + * @param {String} input The Punycoded domain name or email address to + * convert to Unicode. + * @returns {String} The Unicode representation of the given Punycode + * string. + */ +const toUnicode = function (input) { + return mapDomain(input, function (string) { + return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string; + }); +}; +/** + * Converts a Unicode string representing a domain name or an email address to + * Punycode. Only the non-ASCII parts of the domain name will be converted, + * i.e. it doesn't matter if you call it with a domain that's already in + * ASCII. + * @memberOf punycode + * @param {String} input The domain name or email address to convert, as a + * Unicode string. + * @returns {String} The Punycode representation of the given domain name or + * email address. + */ +const toASCII = function (input) { + return mapDomain(input, function (string) { + return regexNonASCII.test(string) ? "xn--" + encode(string) : string; + }); +}; +/** Define the public API */ +const punycode = { + version: "2.3.1", + ucs2: { + decode: ucs2decode, + encode: ucs2encode, + }, + decode: decode, + encode: encode, + toASCII: toASCII, + toUnicode: toUnicode, +}; +const config = { + default: { + options: { + html: false, + xhtmlOut: false, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 100, + }, + components: { + core: {}, + block: {}, + inline: {}, + }, + }, + zero: { + options: { + html: false, + xhtmlOut: false, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 20, + }, + components: { + core: { rules: ["normalize", "block", "inline", "text_join"] }, + block: { rules: ["paragraph"] }, + inline: { + rules: ["text"], + rules2: ["balance_pairs", "fragments_join"], + }, + }, + }, + commonmark: { + options: { + html: true, + xhtmlOut: true, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 20, + }, + components: { + core: { rules: ["normalize", "block", "inline", "text_join"] }, + block: { + rules: [ + "blockquote", + "code", + "fence", + "heading", + "hr", + "html_block", + "lheading", + "list", + "reference", + "paragraph", + ], + }, + inline: { + rules: [ + "autolink", + "backticks", + "emphasis", + "entity", + "escape", + "html_inline", + "image", + "link", + "newline", + "text", + ], + rules2: ["balance_pairs", "emphasis", "fragments_join"], + }, + }, + }, +}; +const BAD_PROTO_RE = /^(vbscript|javascript|file|data):/; +const GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/; +function validateLink(url) { + const str = url.trim().toLowerCase(); + return BAD_PROTO_RE.test(str) ? GOOD_DATA_RE.test(str) : true; +} +const RECODE_HOSTNAME_FOR = ["http:", "https:", "mailto:"]; +function normalizeLink(url) { + const parsed = urlParse(url, true); + if (parsed.hostname) { + if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) + try { + parsed.hostname = punycode.toASCII(parsed.hostname); + } catch (er) {} + } + return encode$2(format(parsed)); +} +function normalizeLinkText(url) { + const parsed = urlParse(url, true); + if (parsed.hostname) { + if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) + try { + parsed.hostname = punycode.toUnicode(parsed.hostname); + } catch (er) {} + } + return decode$2(format(parsed), decode$2.defaultChars + "%"); +} +/** + * class MarkdownIt + * + * Main parser/renderer class. + * + * ##### Usage + * + * ```javascript + * // node.js, "classic" way: + * var MarkdownIt = require('markdown-it'), + * md = new MarkdownIt(); + * var result = md.render('# markdown-it rulezz!'); + * + * // node.js, the same, but with sugar: + * var md = require('markdown-it')(); + * var result = md.render('# markdown-it rulezz!'); + * + * // browser without AMD, added to "window" on script load + * // Note, there are no dash. + * var md = window.markdownit(); + * var result = md.render('# markdown-it rulezz!'); + * ``` + * + * Single line rendering, without paragraph wrap: + * + * ```javascript + * var md = require('markdown-it')(); + * var result = md.renderInline('__markdown-it__ rulezz!'); + * ``` + **/ +/** + * new MarkdownIt([presetName, options]) + * - presetName (String): optional, `commonmark` / `zero` + * - options (Object) + * + * Creates parser instanse with given config. Can be called without `new`. + * + * ##### presetName + * + * MarkdownIt provides named presets as a convenience to quickly + * enable/disable active syntax rules and options for common use cases. + * + * - ["commonmark"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.mjs) - + * configures parser to strict [CommonMark](http://commonmark.org/) mode. + * - [default](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/default.mjs) - + * similar to GFM, used when no preset name given. Enables all available rules, + * but still without html, typographer & autolinker. + * - ["zero"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/zero.mjs) - + * all rules disabled. Useful to quickly setup your config via `.enable()`. + * For example, when you need only `bold` and `italic` markup and nothing else. + * + * ##### options: + * + * - __html__ - `false`. Set `true` to enable HTML tags in source. Be careful! + * That's not safe! You may need external sanitizer to protect output from XSS. + * It's better to extend features via plugins, instead of enabling HTML. + * - __xhtmlOut__ - `false`. Set `true` to add '/' when closing single tags + * (`
`). This is needed only for full CommonMark compatibility. In real + * world you will need HTML output. + * - __breaks__ - `false`. Set `true` to convert `\n` in paragraphs into `
`. + * - __langPrefix__ - `language-`. CSS language class prefix for fenced blocks. + * Can be useful for external highlighters. + * - __linkify__ - `false`. Set `true` to autoconvert URL-like text to links. + * - __typographer__ - `false`. Set `true` to enable [some language-neutral + * replacement](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/replacements.mjs) + + * quotes beautification (smartquotes). + * - __quotes__ - `“”‘’`, String or Array. Double + single quotes replacement + * pairs, when typographer enabled and smartquotes on. For example, you can + * use `'«»„“'` for Russian, `'„“‚‘'` for German, and + * `['«\xA0', '\xA0»', '‹\xA0', '\xA0›']` for French (including nbsp). + * - __highlight__ - `null`. Highlighter function for fenced code blocks. + * Highlighter `function (str, lang)` should return escaped HTML. It can also + * return empty string if the source was not changed and should be escaped + * externaly. If result starts with ` or ``): + * + * ```javascript + * var hljs = require('highlight.js') // https://highlightjs.org/ + * + * // Actual default values + * var md = require('markdown-it')({ + * highlight: function (str, lang) { + * if (lang && hljs.getLanguage(lang)) { + * try { + * return '
' +
+ *                hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
+ *                '
'; + * } catch (__) {} + * } + * + * return '
' + md.utils.escapeHtml(str) + '
'; + * } + * }); + * ``` + * + **/ +function MarkdownIt(presetName, options) { + if (!(this instanceof MarkdownIt)) return new MarkdownIt(presetName, options); + if (!options) { + if (!isString$1(presetName)) { + options = presetName || {}; + presetName = "default"; + } + } + /** + * MarkdownIt#inline -> ParserInline + * + * Instance of [[ParserInline]]. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.inline = new ParserInline(); + /** + * MarkdownIt#block -> ParserBlock + * + * Instance of [[ParserBlock]]. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.block = new ParserBlock(); + /** + * MarkdownIt#core -> Core + * + * Instance of [[Core]] chain executor. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.core = new Core(); + /** + * MarkdownIt#renderer -> Renderer + * + * Instance of [[Renderer]]. Use it to modify output look. Or to add rendering + * rules for new token types, generated by plugins. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * function myToken(tokens, idx, options, env, self) { + * //... + * return result; + * }; + * + * md.renderer.rules['my_token'] = myToken + * ``` + * + * See [[Renderer]] docs and [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs). + **/ + this.renderer = new Renderer(); + /** + * MarkdownIt#linkify -> LinkifyIt + * + * [linkify-it](https://github.com/markdown-it/linkify-it) instance. + * Used by [linkify](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/linkify.mjs) + * rule. + **/ + this.linkify = new LinkifyIt(); + /** + * MarkdownIt#validateLink(url) -> Boolean + * + * Link validation function. CommonMark allows too much in links. By default + * we disable `javascript:`, `vbscript:`, `file:` schemas, and almost all `data:...` schemas + * except some embedded image types. + * + * You can change this behaviour: + * + * ```javascript + * var md = require('markdown-it')(); + * // enable everything + * md.validateLink = function () { return true; } + * ``` + **/ + this.validateLink = validateLink; + /** + * MarkdownIt#normalizeLink(url) -> String + * + * Function used to encode link url to a machine-readable format, + * which includes url-encoding, punycode, etc. + **/ + this.normalizeLink = normalizeLink; + /** + * MarkdownIt#normalizeLinkText(url) -> String + * + * Function used to decode link url to a human-readable format` + **/ + this.normalizeLinkText = normalizeLinkText; + /** + * MarkdownIt#utils -> utils + * + * Assorted utility functions, useful to write plugins. See details + * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/common/utils.mjs). + **/ + this.utils = utils_exports; + /** + * MarkdownIt#helpers -> helpers + * + * Link components parser functions, useful to write plugins. See details + * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/helpers). + **/ + this.helpers = assign$1({}, helpers_exports); + this.options = {}; + this.configure(presetName); + if (options) this.set(options); +} +/** chainable + * MarkdownIt.set(options) + * + * Set parser options (in the same format as in constructor). Probably, you + * will never need it, but you can change options after constructor call. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')() + * .set({ html: true, breaks: true }) + * .set({ typographer, true }); + * ``` + * + * __Note:__ To achieve the best possible performance, don't modify a + * `markdown-it` instance options on the fly. If you need multiple configurations + * it's best to create multiple instances and initialize each with separate + * config. + **/ +MarkdownIt.prototype.set = function (options) { + assign$1(this.options, options); + return this; +}; +/** chainable, internal + * MarkdownIt.configure(presets) + * + * Batch load of all options and compenent settings. This is internal method, + * and you probably will not need it. But if you will - see available presets + * and data structure [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets) + * + * We strongly recommend to use presets instead of direct config loads. That + * will give better compatibility with next versions. + **/ +MarkdownIt.prototype.configure = function (presets) { + const self = this; + if (isString$1(presets)) { + const presetName = presets; + presets = config[presetName]; + if (!presets) throw new Error('Wrong `markdown-it` preset "' + presetName + '", check name'); + } + if (!presets) throw new Error("Wrong `markdown-it` preset, can't be empty"); + if (presets.options) self.set(presets.options); + if (presets.components) + Object.keys(presets.components).forEach(function (name) { + if (presets.components[name].rules) + self[name].ruler.enableOnly(presets.components[name].rules); + if (presets.components[name].rules2) + self[name].ruler2.enableOnly(presets.components[name].rules2); + }); + return this; +}; +/** chainable + * MarkdownIt.enable(list, ignoreInvalid) + * - list (String|Array): rule name or list of rule names to enable + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable list or rules. It will automatically find appropriate components, + * containing rules with given names. If rule not found, and `ignoreInvalid` + * not set - throws exception. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')() + * .enable(['sub', 'sup']) + * .disable('smartquotes'); + * ``` + **/ +MarkdownIt.prototype.enable = function (list, ignoreInvalid) { + let result = []; + if (!Array.isArray(list)) list = [list]; + ["core", "block", "inline"].forEach(function (chain) { + result = result.concat(this[chain].ruler.enable(list, true)); + }, this); + result = result.concat(this.inline.ruler2.enable(list, true)); + const missed = list.filter(function (name) { + return result.indexOf(name) < 0; + }); + if (missed.length && !ignoreInvalid) + throw new Error("MarkdownIt. Failed to enable unknown rule(s): " + missed); + return this; +}; +/** chainable + * MarkdownIt.disable(list, ignoreInvalid) + * - list (String|Array): rule name or list of rule names to disable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * The same as [[MarkdownIt.enable]], but turn specified rules off. + **/ +MarkdownIt.prototype.disable = function (list, ignoreInvalid) { + let result = []; + if (!Array.isArray(list)) list = [list]; + ["core", "block", "inline"].forEach(function (chain) { + result = result.concat(this[chain].ruler.disable(list, true)); + }, this); + result = result.concat(this.inline.ruler2.disable(list, true)); + const missed = list.filter(function (name) { + return result.indexOf(name) < 0; + }); + if (missed.length && !ignoreInvalid) + throw new Error("MarkdownIt. Failed to disable unknown rule(s): " + missed); + return this; +}; +/** chainable + * MarkdownIt.use(plugin, params) + * + * Load specified plugin with given params into current parser instance. + * It's just a sugar to call `plugin(md, params)` with curring. + * + * ##### Example + * + * ```javascript + * var iterator = require('markdown-it-for-inline'); + * var md = require('markdown-it')() + * .use(iterator, 'foo_replace', 'text', function (tokens, idx) { + * tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar'); + * }); + * ``` + **/ +MarkdownIt.prototype.use = function (plugin) { + const args = [this].concat(Array.prototype.slice.call(arguments, 1)); + plugin.apply(plugin, args); + return this; +}; +/** internal + * MarkdownIt.parse(src, env) -> Array + * - src (String): source string + * - env (Object): environment sandbox + * + * Parse input string and return list of block tokens (special token type + * "inline" will contain list of inline tokens). You should not call this + * method directly, until you write custom renderer (for example, to produce + * AST). + * + * `env` is used to pass data between "distributed" rules and return additional + * metadata like reference info, needed for the renderer. It also can be used to + * inject data in specific cases. Usually, you will be ok to pass `{}`, + * and then pass updated object to renderer. + **/ +MarkdownIt.prototype.parse = function (src, env) { + if (typeof src !== "string") throw new Error("Input data should be a String"); + const state = new this.core.State(src, this, env); + this.core.process(state); + return state.tokens; +}; +/** + * MarkdownIt.render(src [, env]) -> String + * - src (String): source string + * - env (Object): environment sandbox + * + * Render markdown string into html. It does all magic for you :). + * + * `env` can be used to inject additional metadata (`{}` by default). + * But you will not need it with high probability. See also comment + * in [[MarkdownIt.parse]]. + **/ +MarkdownIt.prototype.render = function (src, env) { + env = env || {}; + return this.renderer.render(this.parse(src, env), this.options, env); +}; +/** internal + * MarkdownIt.parseInline(src, env) -> Array + * - src (String): source string + * - env (Object): environment sandbox + * + * The same as [[MarkdownIt.parse]] but skip all block rules. It returns the + * block tokens list with the single `inline` element, containing parsed inline + * tokens in `children` property. Also updates `env` object. + **/ +MarkdownIt.prototype.parseInline = function (src, env) { + const state = new this.core.State(src, this, env); + state.inlineMode = true; + this.core.process(state); + return state.tokens; +}; +/** + * MarkdownIt.renderInline(src [, env]) -> String + * - src (String): source string + * - env (Object): environment sandbox + * + * Similar to [[MarkdownIt.render]] but for single paragraph content. Result + * will NOT be wrapped into `

` tags. + **/ +MarkdownIt.prototype.renderInline = function (src, env) { + env = env || {}; + return this.renderer.render(this.parseInline(src, env), this.options, env); +}; +/** + * This is only safe for (and intended to be used for) text node positions. If + * you are using attribute position, then this is only safe if the attribute + * value is surrounded by double-quotes, and is unsafe otherwise (because the + * value could break out of the attribute value and e.g. add another attribute). + */ +function escapeNodeText(str) { + const frag = document.createElement("div"); + D(b`${str}`, frag); + return frag.innerHTML.replaceAll(//gim, ""); +} +var MarkdownDirective = class extends i$5 { + #markdownIt = MarkdownIt({ + highlight: (str, lang) => { + switch (lang) { + case "html": { + const iframe = document.createElement("iframe"); + iframe.classList.add("html-view"); + iframe.srcdoc = str; + iframe.sandbox = ""; + return iframe.innerHTML; + } + default: + return escapeNodeText(str); + } + }, + }); + #lastValue = null; + #lastTagClassMap = null; + update(_part, [value, tagClassMap]) { + if (this.#lastValue === value && JSON.stringify(tagClassMap) === this.#lastTagClassMap) + return E; + this.#lastValue = value; + this.#lastTagClassMap = JSON.stringify(tagClassMap); + return this.render(value, tagClassMap); + } + #originalClassMap = /* @__PURE__ */ new Map(); + #applyTagClassMap(tagClassMap) { + Object.entries(tagClassMap).forEach(([tag]) => { + let tokenName; + switch (tag) { + case "p": + tokenName = "paragraph"; + break; + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + tokenName = "heading"; + break; + case "ul": + tokenName = "bullet_list"; + break; + case "ol": + tokenName = "ordered_list"; + break; + case "li": + tokenName = "list_item"; + break; + case "a": + tokenName = "link"; + break; + case "strong": + tokenName = "strong"; + break; + case "em": + tokenName = "em"; + break; + } + if (!tokenName) return; + const key = `${tokenName}_open`; + this.#markdownIt.renderer.rules[key] = (tokens, idx, options, _env, self) => { + const token = tokens[idx]; + const tokenClasses = tagClassMap[token.tag] ?? []; + for (const clazz of tokenClasses) token.attrJoin("class", clazz); + return self.renderToken(tokens, idx, options); + }; + }); + } + #unapplyTagClassMap() { + for (const [key] of this.#originalClassMap) delete this.#markdownIt.renderer.rules[key]; + this.#originalClassMap.clear(); + } + /** + * Renders the markdown string to HTML using MarkdownIt. + * + * Note: MarkdownIt doesn't enable HTML in its output, so we render the + * value directly without further sanitization. + * @see https://github.com/markdown-it/markdown-it/blob/master/docs/security.md + */ + render(value, tagClassMap) { + if (tagClassMap) this.#applyTagClassMap(tagClassMap); + const htmlString = this.#markdownIt.render(value); + this.#unapplyTagClassMap(); + return o(htmlString); + } +}; +const markdown = e$10(MarkdownDirective); +MarkdownIt(); +var __esDecorate$1 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers$1 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +(() => { + let _classDecorators = [t$1("a2ui-text")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _text_decorators; + let _text_initializers = []; + let _text_extraInitializers = []; + let _usageHint_decorators; + let _usageHint_initializers = []; + let _usageHint_extraInitializers = []; + var Text = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _text_decorators = [n$6()]; + _usageHint_decorators = [ + n$6({ + reflect: true, + attribute: "usage-hint", + }), + ]; + __esDecorate$1( + this, + null, + _text_decorators, + { + kind: "accessor", + name: "text", + static: false, + private: false, + access: { + has: (obj) => "text" in obj, + get: (obj) => obj.text, + set: (obj, value) => { + obj.text = value; + }, + }, + metadata: _metadata, + }, + _text_initializers, + _text_extraInitializers, + ); + __esDecorate$1( + this, + null, + _usageHint_decorators, + { + kind: "accessor", + name: "usageHint", + static: false, + private: false, + access: { + has: (obj) => "usageHint" in obj, + get: (obj) => obj.usageHint, + set: (obj, value) => { + obj.usageHint = value; + }, + }, + metadata: _metadata, + }, + _usageHint_initializers, + _usageHint_extraInitializers, + ); + __esDecorate$1( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Text = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #text_accessor_storage = __runInitializers$1(this, _text_initializers, null); + get text() { + return this.#text_accessor_storage; + } + set text(value) { + this.#text_accessor_storage = value; + } + #usageHint_accessor_storage = + (__runInitializers$1(this, _text_extraInitializers), + __runInitializers$1(this, _usageHint_initializers, null)); + get usageHint() { + return this.#usageHint_accessor_storage; + } + set usageHint(value) { + this.#usageHint_accessor_storage = value; + } + static { + this.styles = [ + structuralStyles, + i$9` + :host { + display: block; + flex: var(--weight); + } + + h1, + h2, + h3, + h4, + h5 { + line-height: inherit; + font: inherit; + } + `, + ]; + } + #renderText() { + let textValue = null; + if (this.text && typeof this.text === "object") { + if ("literalString" in this.text && this.text.literalString) + textValue = this.text.literalString; + else if ("literal" in this.text && this.text.literal !== void 0) + textValue = this.text.literal; + else if (this.text && "path" in this.text && this.text.path) { + if (!this.processor || !this.component) return b`(no model)`; + const value = this.processor.getData( + this.component, + this.text.path, + this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID, + ); + if (value !== null && value !== void 0) textValue = value.toString(); + } + } + if (textValue === null || textValue === void 0) return b`(empty)`; + let markdownText = textValue; + switch (this.usageHint) { + case "h1": + markdownText = `# ${markdownText}`; + break; + case "h2": + markdownText = `## ${markdownText}`; + break; + case "h3": + markdownText = `### ${markdownText}`; + break; + case "h4": + markdownText = `#### ${markdownText}`; + break; + case "h5": + markdownText = `##### ${markdownText}`; + break; + case "caption": + markdownText = `*${markdownText}*`; + break; + default: + break; + } + return b`${markdown(markdownText, appendToAll(this.theme.markdown, ["ol", "ul", "li"], {}))}`; + } + #areHintedStyles(styles) { + if (typeof styles !== "object") return false; + if (Array.isArray(styles)) return false; + if (!styles) return false; + return ["h1", "h2", "h3", "h4", "h5", "h6", "caption", "body"].every((v) => v in styles); + } + #getAdditionalStyles() { + let additionalStyles = {}; + const styles = this.theme.additionalStyles?.Text; + if (!styles) return additionalStyles; + if (this.#areHintedStyles(styles)) additionalStyles = styles[this.usageHint ?? "body"]; + else additionalStyles = styles; + return additionalStyles; + } + render() { + return b`

+ ${this.#renderText()} +
`; + } + constructor() { + super(...arguments); + __runInitializers$1(this, _usageHint_extraInitializers); + } + static { + __runInitializers$1(_classThis, _classExtraInitializers); + } + }; + return _classThis; +})(); +var __esDecorate = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +(() => { + let _classDecorators = [t$1("a2ui-video")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _url_decorators; + let _url_initializers = []; + let _url_extraInitializers = []; + var Video = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _url_decorators = [n$6()]; + __esDecorate( + this, + null, + _url_decorators, + { + kind: "accessor", + name: "url", + static: false, + private: false, + access: { + has: (obj) => "url" in obj, + get: (obj) => obj.url, + set: (obj, value) => { + obj.url = value; + }, + }, + metadata: _metadata, + }, + _url_initializers, + _url_extraInitializers, + ); + __esDecorate( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Video = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #url_accessor_storage = __runInitializers(this, _url_initializers, null); + get url() { + return this.#url_accessor_storage; + } + set url(value) { + this.#url_accessor_storage = value; + } + static { + this.styles = [ + structuralStyles, + i$9` + * { + box-sizing: border-box; + } + + :host { + display: block; + flex: var(--weight); + min-height: 0; + overflow: auto; + } + + video { + display: block; + width: 100%; + } + `, + ]; + } + #renderVideo() { + if (!this.url) return A; + if (this.url && typeof this.url === "object") { + if ("literalString" in this.url) return b`
"; -}; -default_rules.code_block = function (tokens, idx, options, env, slf) { - const token = tokens[idx]; - return ( - "" + - escapeHtml(tokens[idx].content) + - "\n" - ); -}; -default_rules.fence = function (tokens, idx, options, env, slf) { - const token = tokens[idx]; - const info = token.info ? unescapeAll(token.info).trim() : ""; - let langName = ""; - let langAttrs = ""; - if (info) { - const arr = info.split(/(\s+)/g); - langName = arr[0]; - langAttrs = arr.slice(2).join(""); - } - let highlighted; - if (options.highlight) - highlighted = - options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content); - else highlighted = escapeHtml(token.content); - if (highlighted.indexOf("${highlighted}\n`; - } - return `
${highlighted}
\n`; -}; -default_rules.image = function (tokens, idx, options, env, slf) { - const token = tokens[idx]; - token.attrs[token.attrIndex("alt")][1] = slf.renderInlineAsText(token.children, options, env); - return slf.renderToken(tokens, idx, options); -}; -default_rules.hardbreak = function (tokens, idx, options) { - return options.xhtmlOut ? "
\n" : "
\n"; -}; -default_rules.softbreak = function (tokens, idx, options) { - return options.breaks ? (options.xhtmlOut ? "
\n" : "
\n") : "\n"; -}; -default_rules.text = function (tokens, idx) { - return escapeHtml(tokens[idx].content); -}; -default_rules.html_block = function (tokens, idx) { - return tokens[idx].content; -}; -default_rules.html_inline = function (tokens, idx) { - return tokens[idx].content; -}; -/** - * new Renderer() - * - * Creates new [[Renderer]] instance and fill [[Renderer#rules]] with defaults. - **/ -function Renderer() { - /** - * Renderer#rules -> Object - * - * Contains render rules for tokens. Can be updated and extended. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')(); - * - * md.renderer.rules.strong_open = function () { return ''; }; - * md.renderer.rules.strong_close = function () { return ''; }; - * - * var result = md.renderInline(...); - * ``` - * - * Each rule is called as independent static function with fixed signature: - * - * ```javascript - * function my_token_render(tokens, idx, options, env, renderer) { - * // ... - * return renderedHTML; - * } - * ``` - * - * See [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs) - * for more details and examples. - **/ - this.rules = assign$1({}, default_rules); -} -/** - * Renderer.renderAttrs(token) -> String - * - * Render token attributes to string. - **/ -Renderer.prototype.renderAttrs = function renderAttrs(token) { - let i, l, result; - if (!token.attrs) return ""; - result = ""; - for (i = 0, l = token.attrs.length; i < l; i++) - result += " " + escapeHtml(token.attrs[i][0]) + '="' + escapeHtml(token.attrs[i][1]) + '"'; - return result; -}; -/** - * Renderer.renderToken(tokens, idx, options) -> String - * - tokens (Array): list of tokens - * - idx (Numbed): token index to render - * - options (Object): params of parser instance - * - * Default token renderer. Can be overriden by custom function - * in [[Renderer#rules]]. - **/ -Renderer.prototype.renderToken = function renderToken(tokens, idx, options) { - const token = tokens[idx]; - let result = ""; - if (token.hidden) return ""; - if (token.block && token.nesting !== -1 && idx && tokens[idx - 1].hidden) result += "\n"; - result += (token.nesting === -1 ? "\n" : ">"; - return result; -}; -/** - * Renderer.renderInline(tokens, options, env) -> String - * - tokens (Array): list on block tokens to render - * - options (Object): params of parser instance - * - env (Object): additional data from parsed input (references, for example) - * - * The same as [[Renderer.render]], but for single token of `inline` type. - **/ -Renderer.prototype.renderInline = function (tokens, options, env) { - let result = ""; - const rules = this.rules; - for (let i = 0, len = tokens.length; i < len; i++) { - const type = tokens[i].type; - if (typeof rules[type] !== "undefined") result += rules[type](tokens, i, options, env, this); - else result += this.renderToken(tokens, i, options); - } - return result; -}; -/** internal - * Renderer.renderInlineAsText(tokens, options, env) -> String - * - tokens (Array): list on block tokens to render - * - options (Object): params of parser instance - * - env (Object): additional data from parsed input (references, for example) - * - * Special kludge for image `alt` attributes to conform CommonMark spec. - * Don't try to use it! Spec requires to show `alt` content with stripped markup, - * instead of simple escaping. - **/ -Renderer.prototype.renderInlineAsText = function (tokens, options, env) { - let result = ""; - for (let i = 0, len = tokens.length; i < len; i++) - switch (tokens[i].type) { - case "text": - result += tokens[i].content; - break; - case "image": - result += this.renderInlineAsText(tokens[i].children, options, env); - break; - case "html_inline": - case "html_block": - result += tokens[i].content; - break; - case "softbreak": - case "hardbreak": - result += "\n"; - break; - default: - } - return result; -}; -/** - * Renderer.render(tokens, options, env) -> String - * - tokens (Array): list on block tokens to render - * - options (Object): params of parser instance - * - env (Object): additional data from parsed input (references, for example) - * - * Takes token stream and generates HTML. Probably, you will never need to call - * this method directly. - **/ -Renderer.prototype.render = function (tokens, options, env) { - let result = ""; - const rules = this.rules; - for (let i = 0, len = tokens.length; i < len; i++) { - const type = tokens[i].type; - if (type === "inline") result += this.renderInline(tokens[i].children, options, env); - else if (typeof rules[type] !== "undefined") - result += rules[type](tokens, i, options, env, this); - else result += this.renderToken(tokens, i, options, env); - } - return result; -}; -/** - * class Ruler - * - * Helper class, used by [[MarkdownIt#core]], [[MarkdownIt#block]] and - * [[MarkdownIt#inline]] to manage sequences of functions (rules): - * - * - keep rules in defined order - * - assign the name to each rule - * - enable/disable rules - * - add/replace rules - * - allow assign rules to additional named chains (in the same) - * - cacheing lists of active rules - * - * You will not need use this class directly until write plugins. For simple - * rules control use [[MarkdownIt.disable]], [[MarkdownIt.enable]] and - * [[MarkdownIt.use]]. - **/ -/** - * new Ruler() - **/ -function Ruler() { - this.__rules__ = []; - this.__cache__ = null; -} -Ruler.prototype.__find__ = function (name) { - for (let i = 0; i < this.__rules__.length; i++) if (this.__rules__[i].name === name) return i; - return -1; -}; -Ruler.prototype.__compile__ = function () { - const self = this; - const chains = [""]; - self.__rules__.forEach(function (rule) { - if (!rule.enabled) return; - rule.alt.forEach(function (altName) { - if (chains.indexOf(altName) < 0) chains.push(altName); - }); - }); - self.__cache__ = {}; - chains.forEach(function (chain) { - self.__cache__[chain] = []; - self.__rules__.forEach(function (rule) { - if (!rule.enabled) return; - if (chain && rule.alt.indexOf(chain) < 0) return; - self.__cache__[chain].push(rule.fn); - }); - }); -}; -/** - * Ruler.at(name, fn [, options]) - * - name (String): rule name to replace. - * - fn (Function): new rule function. - * - options (Object): new rule options (not mandatory). - * - * Replace rule by name with new function & options. Throws error if name not - * found. - * - * ##### Options: - * - * - __alt__ - array with names of "alternate" chains. - * - * ##### Example - * - * Replace existing typographer replacement rule with new one: - * - * ```javascript - * var md = require('markdown-it')(); - * - * md.core.ruler.at('replacements', function replace(state) { - * //... - * }); - * ``` - **/ -Ruler.prototype.at = function (name, fn, options) { - const index = this.__find__(name); - const opt = options || {}; - if (index === -1) throw new Error("Parser rule not found: " + name); - this.__rules__[index].fn = fn; - this.__rules__[index].alt = opt.alt || []; - this.__cache__ = null; -}; -/** - * Ruler.before(beforeName, ruleName, fn [, options]) - * - beforeName (String): new rule will be added before this one. - * - ruleName (String): name of added rule. - * - fn (Function): rule function. - * - options (Object): rule options (not mandatory). - * - * Add new rule to chain before one with given name. See also - * [[Ruler.after]], [[Ruler.push]]. - * - * ##### Options: - * - * - __alt__ - array with names of "alternate" chains. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')(); - * - * md.block.ruler.before('paragraph', 'my_rule', function replace(state) { - * //... - * }); - * ``` - **/ -Ruler.prototype.before = function (beforeName, ruleName, fn, options) { - const index = this.__find__(beforeName); - const opt = options || {}; - if (index === -1) throw new Error("Parser rule not found: " + beforeName); - this.__rules__.splice(index, 0, { - name: ruleName, - enabled: true, - fn, - alt: opt.alt || [], - }); - this.__cache__ = null; -}; -/** - * Ruler.after(afterName, ruleName, fn [, options]) - * - afterName (String): new rule will be added after this one. - * - ruleName (String): name of added rule. - * - fn (Function): rule function. - * - options (Object): rule options (not mandatory). - * - * Add new rule to chain after one with given name. See also - * [[Ruler.before]], [[Ruler.push]]. - * - * ##### Options: - * - * - __alt__ - array with names of "alternate" chains. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')(); - * - * md.inline.ruler.after('text', 'my_rule', function replace(state) { - * //... - * }); - * ``` - **/ -Ruler.prototype.after = function (afterName, ruleName, fn, options) { - const index = this.__find__(afterName); - const opt = options || {}; - if (index === -1) throw new Error("Parser rule not found: " + afterName); - this.__rules__.splice(index + 1, 0, { - name: ruleName, - enabled: true, - fn, - alt: opt.alt || [], - }); - this.__cache__ = null; -}; -/** - * Ruler.push(ruleName, fn [, options]) - * - ruleName (String): name of added rule. - * - fn (Function): rule function. - * - options (Object): rule options (not mandatory). - * - * Push new rule to the end of chain. See also - * [[Ruler.before]], [[Ruler.after]]. - * - * ##### Options: - * - * - __alt__ - array with names of "alternate" chains. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')(); - * - * md.core.ruler.push('my_rule', function replace(state) { - * //... - * }); - * ``` - **/ -Ruler.prototype.push = function (ruleName, fn, options) { - const opt = options || {}; - this.__rules__.push({ - name: ruleName, - enabled: true, - fn, - alt: opt.alt || [], - }); - this.__cache__ = null; -}; -/** - * Ruler.enable(list [, ignoreInvalid]) -> Array - * - list (String|Array): list of rule names to enable. - * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. - * - * Enable rules with given names. If any rule name not found - throw Error. - * Errors can be disabled by second param. - * - * Returns list of found rule names (if no exception happened). - * - * See also [[Ruler.disable]], [[Ruler.enableOnly]]. - **/ -Ruler.prototype.enable = function (list, ignoreInvalid) { - if (!Array.isArray(list)) list = [list]; - const result = []; - list.forEach(function (name) { - const idx = this.__find__(name); - if (idx < 0) { - if (ignoreInvalid) return; - throw new Error("Rules manager: invalid rule name " + name); - } - this.__rules__[idx].enabled = true; - result.push(name); - }, this); - this.__cache__ = null; - return result; -}; -/** - * Ruler.enableOnly(list [, ignoreInvalid]) - * - list (String|Array): list of rule names to enable (whitelist). - * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. - * - * Enable rules with given names, and disable everything else. If any rule name - * not found - throw Error. Errors can be disabled by second param. - * - * See also [[Ruler.disable]], [[Ruler.enable]]. - **/ -Ruler.prototype.enableOnly = function (list, ignoreInvalid) { - if (!Array.isArray(list)) list = [list]; - this.__rules__.forEach(function (rule) { - rule.enabled = false; - }); - this.enable(list, ignoreInvalid); -}; -/** - * Ruler.disable(list [, ignoreInvalid]) -> Array - * - list (String|Array): list of rule names to disable. - * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. - * - * Disable rules with given names. If any rule name not found - throw Error. - * Errors can be disabled by second param. - * - * Returns list of found rule names (if no exception happened). - * - * See also [[Ruler.enable]], [[Ruler.enableOnly]]. - **/ -Ruler.prototype.disable = function (list, ignoreInvalid) { - if (!Array.isArray(list)) list = [list]; - const result = []; - list.forEach(function (name) { - const idx = this.__find__(name); - if (idx < 0) { - if (ignoreInvalid) return; - throw new Error("Rules manager: invalid rule name " + name); - } - this.__rules__[idx].enabled = false; - result.push(name); - }, this); - this.__cache__ = null; - return result; -}; -/** - * Ruler.getRules(chainName) -> Array - * - * Return array of active functions (rules) for given chain name. It analyzes - * rules configuration, compiles caches if not exists and returns result. - * - * Default chain name is `''` (empty string). It can't be skipped. That's - * done intentionally, to keep signature monomorphic for high speed. - **/ -Ruler.prototype.getRules = function (chainName) { - if (this.__cache__ === null) this.__compile__(); - return this.__cache__[chainName] || []; -}; -/** - * class Token - **/ -/** - * new Token(type, tag, nesting) - * - * Create new token and fill passed properties. - **/ -function Token(type, tag, nesting) { - /** - * Token#type -> String - * - * Type of the token (string, e.g. "paragraph_open") - **/ - this.type = type; - /** - * Token#tag -> String - * - * html tag name, e.g. "p" - **/ - this.tag = tag; - /** - * Token#attrs -> Array - * - * Html attributes. Format: `[ [ name1, value1 ], [ name2, value2 ] ]` - **/ - this.attrs = null; - /** - * Token#map -> Array - * - * Source map info. Format: `[ line_begin, line_end ]` - **/ - this.map = null; - /** - * Token#nesting -> Number - * - * Level change (number in {-1, 0, 1} set), where: - * - * - `1` means the tag is opening - * - `0` means the tag is self-closing - * - `-1` means the tag is closing - **/ - this.nesting = nesting; - /** - * Token#level -> Number - * - * nesting level, the same as `state.level` - **/ - this.level = 0; - /** - * Token#children -> Array - * - * An array of child nodes (inline and img tokens) - **/ - this.children = null; - /** - * Token#content -> String - * - * In a case of self-closing tag (code, html, fence, etc.), - * it has contents of this tag. - **/ - this.content = ""; - /** - * Token#markup -> String - * - * '*' or '_' for emphasis, fence string for fence, etc. - **/ - this.markup = ""; - /** - * Token#info -> String - * - * Additional information: - * - * - Info string for "fence" tokens - * - The value "auto" for autolink "link_open" and "link_close" tokens - * - The string value of the item marker for ordered-list "list_item_open" tokens - **/ - this.info = ""; - /** - * Token#meta -> Object - * - * A place for plugins to store an arbitrary data - **/ - this.meta = null; - /** - * Token#block -> Boolean - * - * True for block-level tokens, false for inline tokens. - * Used in renderer to calculate line breaks - **/ - this.block = false; - /** - * Token#hidden -> Boolean - * - * If it's true, ignore this element when rendering. Used for tight lists - * to hide paragraphs. - **/ - this.hidden = false; -} -/** - * Token.attrIndex(name) -> Number - * - * Search attribute index by name. - **/ -Token.prototype.attrIndex = function attrIndex(name) { - if (!this.attrs) return -1; - const attrs = this.attrs; - for (let i = 0, len = attrs.length; i < len; i++) if (attrs[i][0] === name) return i; - return -1; -}; -/** - * Token.attrPush(attrData) - * - * Add `[ name, value ]` attribute to list. Init attrs if necessary - **/ -Token.prototype.attrPush = function attrPush(attrData) { - if (this.attrs) this.attrs.push(attrData); - else this.attrs = [attrData]; -}; -/** - * Token.attrSet(name, value) - * - * Set `name` attribute to `value`. Override old value if exists. - **/ -Token.prototype.attrSet = function attrSet(name, value) { - const idx = this.attrIndex(name); - const attrData = [name, value]; - if (idx < 0) this.attrPush(attrData); - else this.attrs[idx] = attrData; -}; -/** - * Token.attrGet(name) - * - * Get the value of attribute `name`, or null if it does not exist. - **/ -Token.prototype.attrGet = function attrGet(name) { - const idx = this.attrIndex(name); - let value = null; - if (idx >= 0) value = this.attrs[idx][1]; - return value; -}; -/** - * Token.attrJoin(name, value) - * - * Join value to existing attribute via space. Or create new attribute if not - * exists. Useful to operate with token classes. - **/ -Token.prototype.attrJoin = function attrJoin(name, value) { - const idx = this.attrIndex(name); - if (idx < 0) this.attrPush([name, value]); - else this.attrs[idx][1] = this.attrs[idx][1] + " " + value; -}; -function StateCore(src, md, env) { - this.src = src; - this.env = env; - this.tokens = []; - this.inlineMode = false; - this.md = md; -} -StateCore.prototype.Token = Token; -const NEWLINES_RE = /\r\n?|\n/g; -const NULL_RE = /\0/g; -function normalize(state) { - let str; - str = state.src.replace(NEWLINES_RE, "\n"); - str = str.replace(NULL_RE, "�"); - state.src = str; -} -function block(state) { - let token; - if (state.inlineMode) { - token = new state.Token("inline", "", 0); - token.content = state.src; - token.map = [0, 1]; - token.children = []; - state.tokens.push(token); - } else state.md.block.parse(state.src, state.md, state.env, state.tokens); -} -function inline(state) { - const tokens = state.tokens; - for (let i = 0, l = tokens.length; i < l; i++) { - const tok = tokens[i]; - if (tok.type === "inline") - state.md.inline.parse(tok.content, state.md, state.env, tok.children); - } -} -function isLinkOpen$1(str) { - return /^\s]/i.test(str); -} -function isLinkClose$1(str) { - return /^<\/a\s*>/i.test(str); -} -function linkify$1(state) { - const blockTokens = state.tokens; - if (!state.md.options.linkify) return; - for (let j = 0, l = blockTokens.length; j < l; j++) { - if (blockTokens[j].type !== "inline" || !state.md.linkify.pretest(blockTokens[j].content)) - continue; - let tokens = blockTokens[j].children; - let htmlLinkLevel = 0; - for (let i = tokens.length - 1; i >= 0; i--) { - const currentToken = tokens[i]; - if (currentToken.type === "link_close") { - i--; - while (tokens[i].level !== currentToken.level && tokens[i].type !== "link_open") i--; - continue; - } - if (currentToken.type === "html_inline") { - if (isLinkOpen$1(currentToken.content) && htmlLinkLevel > 0) htmlLinkLevel--; - if (isLinkClose$1(currentToken.content)) htmlLinkLevel++; - } - if (htmlLinkLevel > 0) continue; - if (currentToken.type === "text" && state.md.linkify.test(currentToken.content)) { - const text = currentToken.content; - let links = state.md.linkify.match(text); - const nodes = []; - let level = currentToken.level; - let lastPos = 0; - if ( - links.length > 0 && - links[0].index === 0 && - i > 0 && - tokens[i - 1].type === "text_special" - ) - links = links.slice(1); - for (let ln = 0; ln < links.length; ln++) { - const url = links[ln].url; - const fullUrl = state.md.normalizeLink(url); - if (!state.md.validateLink(fullUrl)) continue; - let urlText = links[ln].text; - if (!links[ln].schema) - urlText = state.md.normalizeLinkText("http://" + urlText).replace(/^http:\/\//, ""); - else if (links[ln].schema === "mailto:" && !/^mailto:/i.test(urlText)) - urlText = state.md.normalizeLinkText("mailto:" + urlText).replace(/^mailto:/, ""); - else urlText = state.md.normalizeLinkText(urlText); - const pos = links[ln].index; - if (pos > lastPos) { - const token = new state.Token("text", "", 0); - token.content = text.slice(lastPos, pos); - token.level = level; - nodes.push(token); - } - const token_o = new state.Token("link_open", "a", 1); - token_o.attrs = [["href", fullUrl]]; - token_o.level = level++; - token_o.markup = "linkify"; - token_o.info = "auto"; - nodes.push(token_o); - const token_t = new state.Token("text", "", 0); - token_t.content = urlText; - token_t.level = level; - nodes.push(token_t); - const token_c = new state.Token("link_close", "a", -1); - token_c.level = --level; - token_c.markup = "linkify"; - token_c.info = "auto"; - nodes.push(token_c); - lastPos = links[ln].lastIndex; - } - if (lastPos < text.length) { - const token = new state.Token("text", "", 0); - token.content = text.slice(lastPos); - token.level = level; - nodes.push(token); - } - blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes); - } - } - } -} -const RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--/; -const SCOPED_ABBR_TEST_RE = /\((c|tm|r)\)/i; -const SCOPED_ABBR_RE = /\((c|tm|r)\)/gi; -const SCOPED_ABBR = { - c: "©", - r: "®", - tm: "™", -}; -function replaceFn(match, name) { - return SCOPED_ABBR[name.toLowerCase()]; -} -function replace_scoped(inlineTokens) { - let inside_autolink = 0; - for (let i = inlineTokens.length - 1; i >= 0; i--) { - const token = inlineTokens[i]; - if (token.type === "text" && !inside_autolink) - token.content = token.content.replace(SCOPED_ABBR_RE, replaceFn); - if (token.type === "link_open" && token.info === "auto") inside_autolink--; - if (token.type === "link_close" && token.info === "auto") inside_autolink++; - } -} -function replace_rare(inlineTokens) { - let inside_autolink = 0; - for (let i = inlineTokens.length - 1; i >= 0; i--) { - const token = inlineTokens[i]; - if (token.type === "text" && !inside_autolink) { - if (RARE_RE.test(token.content)) - token.content = token.content - .replace(/\+-/g, "±") - .replace(/\.{2,}/g, "…") - .replace(/([?!])…/g, "$1..") - .replace(/([?!]){4,}/g, "$1$1$1") - .replace(/,{2,}/g, ",") - .replace(/(^|[^-])---(?=[^-]|$)/gm, "$1—") - .replace(/(^|\s)--(?=\s|$)/gm, "$1–") - .replace(/(^|[^-\s])--(?=[^-\s]|$)/gm, "$1–"); - } - if (token.type === "link_open" && token.info === "auto") inside_autolink--; - if (token.type === "link_close" && token.info === "auto") inside_autolink++; - } -} -function replace(state) { - let blkIdx; - if (!state.md.options.typographer) return; - for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { - if (state.tokens[blkIdx].type !== "inline") continue; - if (SCOPED_ABBR_TEST_RE.test(state.tokens[blkIdx].content)) - replace_scoped(state.tokens[blkIdx].children); - if (RARE_RE.test(state.tokens[blkIdx].content)) replace_rare(state.tokens[blkIdx].children); - } -} -const QUOTE_TEST_RE = /['"]/; -const QUOTE_RE = /['"]/g; -const APOSTROPHE = "’"; -function replaceAt(str, index, ch) { - return str.slice(0, index) + ch + str.slice(index + 1); -} -function process_inlines(tokens, state) { - let j; - const stack = []; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - const thisLevel = tokens[i].level; - for (j = stack.length - 1; j >= 0; j--) if (stack[j].level <= thisLevel) break; - stack.length = j + 1; - if (token.type !== "text") continue; - let text = token.content; - let pos = 0; - let max = text.length; - OUTER: while (pos < max) { - QUOTE_RE.lastIndex = pos; - const t = QUOTE_RE.exec(text); - if (!t) break; - let canOpen = true; - let canClose = true; - pos = t.index + 1; - const isSingle = t[0] === "'"; - let lastChar = 32; - if (t.index - 1 >= 0) lastChar = text.charCodeAt(t.index - 1); - else - for (j = i - 1; j >= 0; j--) { - if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; - if (!tokens[j].content) continue; - lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1); - break; - } - let nextChar = 32; - if (pos < max) nextChar = text.charCodeAt(pos); - else - for (j = i + 1; j < tokens.length; j++) { - if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; - if (!tokens[j].content) continue; - nextChar = tokens[j].content.charCodeAt(0); - break; - } - const isLastPunctChar = - isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); - const isNextPunctChar = - isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); - const isLastWhiteSpace = isWhiteSpace(lastChar); - const isNextWhiteSpace = isWhiteSpace(nextChar); - if (isNextWhiteSpace) canOpen = false; - else if (isNextPunctChar) { - if (!(isLastWhiteSpace || isLastPunctChar)) canOpen = false; - } - if (isLastWhiteSpace) canClose = false; - else if (isLastPunctChar) { - if (!(isNextWhiteSpace || isNextPunctChar)) canClose = false; - } - if (nextChar === 34 && t[0] === '"') { - if (lastChar >= 48 && lastChar <= 57) canClose = canOpen = false; - } - if (canOpen && canClose) { - canOpen = isLastPunctChar; - canClose = isNextPunctChar; - } - if (!canOpen && !canClose) { - if (isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); - continue; - } - if (canClose) - for (j = stack.length - 1; j >= 0; j--) { - let item = stack[j]; - if (stack[j].level < thisLevel) break; - if (item.single === isSingle && stack[j].level === thisLevel) { - item = stack[j]; - let openQuote; - let closeQuote; - if (isSingle) { - openQuote = state.md.options.quotes[2]; - closeQuote = state.md.options.quotes[3]; - } else { - openQuote = state.md.options.quotes[0]; - closeQuote = state.md.options.quotes[1]; - } - token.content = replaceAt(token.content, t.index, closeQuote); - tokens[item.token].content = replaceAt(tokens[item.token].content, item.pos, openQuote); - pos += closeQuote.length - 1; - if (item.token === i) pos += openQuote.length - 1; - text = token.content; - max = text.length; - stack.length = j; - continue OUTER; - } - } - if (canOpen) - stack.push({ - token: i, - pos: t.index, - single: isSingle, - level: thisLevel, - }); - else if (canClose && isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); - } - } -} -function smartquotes(state) { - if (!state.md.options.typographer) return; - for (let blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { - if (state.tokens[blkIdx].type !== "inline" || !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) - continue; - process_inlines(state.tokens[blkIdx].children, state); - } -} -function text_join(state) { - let curr, last; - const blockTokens = state.tokens; - const l = blockTokens.length; - for (let j = 0; j < l; j++) { - if (blockTokens[j].type !== "inline") continue; - const tokens = blockTokens[j].children; - const max = tokens.length; - for (curr = 0; curr < max; curr++) - if (tokens[curr].type === "text_special") tokens[curr].type = "text"; - for (curr = last = 0; curr < max; curr++) - if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") - tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; - else { - if (curr !== last) tokens[last] = tokens[curr]; - last++; - } - if (curr !== last) tokens.length = last; - } -} -/** internal - * class Core - * - * Top-level rules executor. Glues block/inline parsers and does intermediate - * transformations. - **/ -const _rules$2 = [ - ["normalize", normalize], - ["block", block], - ["inline", inline], - ["linkify", linkify$1], - ["replacements", replace], - ["smartquotes", smartquotes], - ["text_join", text_join], -]; -/** - * new Core() - **/ -function Core() { - /** - * Core#ruler -> Ruler - * - * [[Ruler]] instance. Keep configuration of core rules. - **/ - this.ruler = new Ruler(); - for (let i = 0; i < _rules$2.length; i++) this.ruler.push(_rules$2[i][0], _rules$2[i][1]); -} -/** - * Core.process(state) - * - * Executes core chain rules. - **/ -Core.prototype.process = function (state) { - const rules = this.ruler.getRules(""); - for (let i = 0, l = rules.length; i < l; i++) rules[i](state); -}; -Core.prototype.State = StateCore; -function StateBlock(src, md, env, tokens) { - this.src = src; - this.md = md; - this.env = env; - this.tokens = tokens; - this.bMarks = []; - this.eMarks = []; - this.tShift = []; - this.sCount = []; - this.bsCount = []; - this.blkIndent = 0; - this.line = 0; - this.lineMax = 0; - this.tight = false; - this.ddIndent = -1; - this.listIndent = -1; - this.parentType = "root"; - this.level = 0; - const s = this.src; - for ( - let start = 0, pos = 0, indent = 0, offset = 0, len = s.length, indent_found = false; - pos < len; - pos++ - ) { - const ch = s.charCodeAt(pos); - if (!indent_found) - if (isSpace(ch)) { - indent++; - if (ch === 9) offset += 4 - (offset % 4); - else offset++; - continue; - } else indent_found = true; - if (ch === 10 || pos === len - 1) { - if (ch !== 10) pos++; - this.bMarks.push(start); - this.eMarks.push(pos); - this.tShift.push(indent); - this.sCount.push(offset); - this.bsCount.push(0); - indent_found = false; - indent = 0; - offset = 0; - start = pos + 1; - } - } - this.bMarks.push(s.length); - this.eMarks.push(s.length); - this.tShift.push(0); - this.sCount.push(0); - this.bsCount.push(0); - this.lineMax = this.bMarks.length - 1; -} -StateBlock.prototype.push = function (type, tag, nesting) { - const token = new Token(type, tag, nesting); - token.block = true; - if (nesting < 0) this.level--; - token.level = this.level; - if (nesting > 0) this.level++; - this.tokens.push(token); - return token; -}; -StateBlock.prototype.isEmpty = function isEmpty(line) { - return this.bMarks[line] + this.tShift[line] >= this.eMarks[line]; -}; -StateBlock.prototype.skipEmptyLines = function skipEmptyLines(from) { - for (let max = this.lineMax; from < max; from++) - if (this.bMarks[from] + this.tShift[from] < this.eMarks[from]) break; - return from; -}; -StateBlock.prototype.skipSpaces = function skipSpaces(pos) { - for (let max = this.src.length; pos < max; pos++) if (!isSpace(this.src.charCodeAt(pos))) break; - return pos; -}; -StateBlock.prototype.skipSpacesBack = function skipSpacesBack(pos, min) { - if (pos <= min) return pos; - while (pos > min) if (!isSpace(this.src.charCodeAt(--pos))) return pos + 1; - return pos; -}; -StateBlock.prototype.skipChars = function skipChars(pos, code) { - for (let max = this.src.length; pos < max; pos++) if (this.src.charCodeAt(pos) !== code) break; - return pos; -}; -StateBlock.prototype.skipCharsBack = function skipCharsBack(pos, code, min) { - if (pos <= min) return pos; - while (pos > min) if (code !== this.src.charCodeAt(--pos)) return pos + 1; - return pos; -}; -StateBlock.prototype.getLines = function getLines(begin, end, indent, keepLastLF) { - if (begin >= end) return ""; - const queue = new Array(end - begin); - for (let i = 0, line = begin; line < end; line++, i++) { - let lineIndent = 0; - const lineStart = this.bMarks[line]; - let first = lineStart; - let last; - if (line + 1 < end || keepLastLF) last = this.eMarks[line] + 1; - else last = this.eMarks[line]; - while (first < last && lineIndent < indent) { - const ch = this.src.charCodeAt(first); - if (isSpace(ch)) - if (ch === 9) lineIndent += 4 - ((lineIndent + this.bsCount[line]) % 4); - else lineIndent++; - else if (first - lineStart < this.tShift[line]) lineIndent++; - else break; - first++; - } - if (lineIndent > indent) - queue[i] = new Array(lineIndent - indent + 1).join(" ") + this.src.slice(first, last); - else queue[i] = this.src.slice(first, last); - } - return queue.join(""); -}; -StateBlock.prototype.Token = Token; -const MAX_AUTOCOMPLETED_CELLS = 65536; -function getLine(state, line) { - const pos = state.bMarks[line] + state.tShift[line]; - const max = state.eMarks[line]; - return state.src.slice(pos, max); -} -function escapedSplit(str) { - const result = []; - const max = str.length; - let pos = 0; - let ch = str.charCodeAt(pos); - let isEscaped = false; - let lastPos = 0; - let current = ""; - while (pos < max) { - if (ch === 124) - if (!isEscaped) { - result.push(current + str.substring(lastPos, pos)); - current = ""; - lastPos = pos + 1; - } else { - current += str.substring(lastPos, pos - 1); - lastPos = pos; - } - isEscaped = ch === 92; - pos++; - ch = str.charCodeAt(pos); - } - result.push(current + str.substring(lastPos)); - return result; -} -function table(state, startLine, endLine, silent) { - if (startLine + 2 > endLine) return false; - let nextLine = startLine + 1; - if (state.sCount[nextLine] < state.blkIndent) return false; - if (state.sCount[nextLine] - state.blkIndent >= 4) return false; - let pos = state.bMarks[nextLine] + state.tShift[nextLine]; - if (pos >= state.eMarks[nextLine]) return false; - const firstCh = state.src.charCodeAt(pos++); - if (firstCh !== 124 && firstCh !== 45 && firstCh !== 58) return false; - if (pos >= state.eMarks[nextLine]) return false; - const secondCh = state.src.charCodeAt(pos++); - if (secondCh !== 124 && secondCh !== 45 && secondCh !== 58 && !isSpace(secondCh)) return false; - if (firstCh === 45 && isSpace(secondCh)) return false; - while (pos < state.eMarks[nextLine]) { - const ch = state.src.charCodeAt(pos); - if (ch !== 124 && ch !== 45 && ch !== 58 && !isSpace(ch)) return false; - pos++; - } - let lineText = getLine(state, startLine + 1); - let columns = lineText.split("|"); - const aligns = []; - for (let i = 0; i < columns.length; i++) { - const t = columns[i].trim(); - if (!t) - if (i === 0 || i === columns.length - 1) continue; - else return false; - if (!/^:?-+:?$/.test(t)) return false; - if (t.charCodeAt(t.length - 1) === 58) aligns.push(t.charCodeAt(0) === 58 ? "center" : "right"); - else if (t.charCodeAt(0) === 58) aligns.push("left"); - else aligns.push(""); - } - lineText = getLine(state, startLine).trim(); - if (lineText.indexOf("|") === -1) return false; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - columns = escapedSplit(lineText); - if (columns.length && columns[0] === "") columns.shift(); - if (columns.length && columns[columns.length - 1] === "") columns.pop(); - const columnCount = columns.length; - if (columnCount === 0 || columnCount !== aligns.length) return false; - if (silent) return true; - const oldParentType = state.parentType; - state.parentType = "table"; - const terminatorRules = state.md.block.ruler.getRules("blockquote"); - const token_to = state.push("table_open", "table", 1); - const tableLines = [startLine, 0]; - token_to.map = tableLines; - const token_tho = state.push("thead_open", "thead", 1); - token_tho.map = [startLine, startLine + 1]; - const token_htro = state.push("tr_open", "tr", 1); - token_htro.map = [startLine, startLine + 1]; - for (let i = 0; i < columns.length; i++) { - const token_ho = state.push("th_open", "th", 1); - if (aligns[i]) token_ho.attrs = [["style", "text-align:" + aligns[i]]]; - const token_il = state.push("inline", "", 0); - token_il.content = columns[i].trim(); - token_il.children = []; - state.push("th_close", "th", -1); - } - state.push("tr_close", "tr", -1); - state.push("thead_close", "thead", -1); - let tbodyLines; - let autocompletedCells = 0; - for (nextLine = startLine + 2; nextLine < endLine; nextLine++) { - if (state.sCount[nextLine] < state.blkIndent) break; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - if (terminate) break; - lineText = getLine(state, nextLine).trim(); - if (!lineText) break; - if (state.sCount[nextLine] - state.blkIndent >= 4) break; - columns = escapedSplit(lineText); - if (columns.length && columns[0] === "") columns.shift(); - if (columns.length && columns[columns.length - 1] === "") columns.pop(); - autocompletedCells += columnCount - columns.length; - if (autocompletedCells > MAX_AUTOCOMPLETED_CELLS) break; - if (nextLine === startLine + 2) { - const token_tbo = state.push("tbody_open", "tbody", 1); - token_tbo.map = tbodyLines = [startLine + 2, 0]; - } - const token_tro = state.push("tr_open", "tr", 1); - token_tro.map = [nextLine, nextLine + 1]; - for (let i = 0; i < columnCount; i++) { - const token_tdo = state.push("td_open", "td", 1); - if (aligns[i]) token_tdo.attrs = [["style", "text-align:" + aligns[i]]]; - const token_il = state.push("inline", "", 0); - token_il.content = columns[i] ? columns[i].trim() : ""; - token_il.children = []; - state.push("td_close", "td", -1); - } - state.push("tr_close", "tr", -1); - } - if (tbodyLines) { - state.push("tbody_close", "tbody", -1); - tbodyLines[1] = nextLine; - } - state.push("table_close", "table", -1); - tableLines[1] = nextLine; - state.parentType = oldParentType; - state.line = nextLine; - return true; -} -function code(state, startLine, endLine) { - if (state.sCount[startLine] - state.blkIndent < 4) return false; - let nextLine = startLine + 1; - let last = nextLine; - while (nextLine < endLine) { - if (state.isEmpty(nextLine)) { - nextLine++; - continue; - } - if (state.sCount[nextLine] - state.blkIndent >= 4) { - nextLine++; - last = nextLine; - continue; - } - break; - } - state.line = last; - const token = state.push("code_block", "code", 0); - token.content = state.getLines(startLine, last, 4 + state.blkIndent, false) + "\n"; - token.map = [startLine, state.line]; - return true; -} -function fence(state, startLine, endLine, silent) { - let pos = state.bMarks[startLine] + state.tShift[startLine]; - let max = state.eMarks[startLine]; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - if (pos + 3 > max) return false; - const marker = state.src.charCodeAt(pos); - if (marker !== 126 && marker !== 96) return false; - let mem = pos; - pos = state.skipChars(pos, marker); - let len = pos - mem; - if (len < 3) return false; - const markup = state.src.slice(mem, pos); - const params = state.src.slice(pos, max); - if (marker === 96) { - if (params.indexOf(String.fromCharCode(marker)) >= 0) return false; - } - if (silent) return true; - let nextLine = startLine; - let haveEndMarker = false; - for (;;) { - nextLine++; - if (nextLine >= endLine) break; - pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]; - max = state.eMarks[nextLine]; - if (pos < max && state.sCount[nextLine] < state.blkIndent) break; - if (state.src.charCodeAt(pos) !== marker) continue; - if (state.sCount[nextLine] - state.blkIndent >= 4) continue; - pos = state.skipChars(pos, marker); - if (pos - mem < len) continue; - pos = state.skipSpaces(pos); - if (pos < max) continue; - haveEndMarker = true; - break; - } - len = state.sCount[startLine]; - state.line = nextLine + (haveEndMarker ? 1 : 0); - const token = state.push("fence", "code", 0); - token.info = params; - token.content = state.getLines(startLine + 1, nextLine, len, true); - token.markup = markup; - token.map = [startLine, state.line]; - return true; -} -function blockquote(state, startLine, endLine, silent) { - let pos = state.bMarks[startLine] + state.tShift[startLine]; - let max = state.eMarks[startLine]; - const oldLineMax = state.lineMax; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - if (state.src.charCodeAt(pos) !== 62) return false; - if (silent) return true; - const oldBMarks = []; - const oldBSCount = []; - const oldSCount = []; - const oldTShift = []; - const terminatorRules = state.md.block.ruler.getRules("blockquote"); - const oldParentType = state.parentType; - state.parentType = "blockquote"; - let lastLineEmpty = false; - let nextLine; - for (nextLine = startLine; nextLine < endLine; nextLine++) { - const isOutdented = state.sCount[nextLine] < state.blkIndent; - pos = state.bMarks[nextLine] + state.tShift[nextLine]; - max = state.eMarks[nextLine]; - if (pos >= max) break; - if (state.src.charCodeAt(pos++) === 62 && !isOutdented) { - let initial = state.sCount[nextLine] + 1; - let spaceAfterMarker; - let adjustTab; - if (state.src.charCodeAt(pos) === 32) { - pos++; - initial++; - adjustTab = false; - spaceAfterMarker = true; - } else if (state.src.charCodeAt(pos) === 9) { - spaceAfterMarker = true; - if ((state.bsCount[nextLine] + initial) % 4 === 3) { - pos++; - initial++; - adjustTab = false; - } else adjustTab = true; - } else spaceAfterMarker = false; - let offset = initial; - oldBMarks.push(state.bMarks[nextLine]); - state.bMarks[nextLine] = pos; - while (pos < max) { - const ch = state.src.charCodeAt(pos); - if (isSpace(ch)) - if (ch === 9) - offset += 4 - ((offset + state.bsCount[nextLine] + (adjustTab ? 1 : 0)) % 4); - else offset++; - else break; - pos++; - } - lastLineEmpty = pos >= max; - oldBSCount.push(state.bsCount[nextLine]); - state.bsCount[nextLine] = state.sCount[nextLine] + 1 + (spaceAfterMarker ? 1 : 0); - oldSCount.push(state.sCount[nextLine]); - state.sCount[nextLine] = offset - initial; - oldTShift.push(state.tShift[nextLine]); - state.tShift[nextLine] = pos - state.bMarks[nextLine]; - continue; - } - if (lastLineEmpty) break; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - if (terminate) { - state.lineMax = nextLine; - if (state.blkIndent !== 0) { - oldBMarks.push(state.bMarks[nextLine]); - oldBSCount.push(state.bsCount[nextLine]); - oldTShift.push(state.tShift[nextLine]); - oldSCount.push(state.sCount[nextLine]); - state.sCount[nextLine] -= state.blkIndent; - } - break; - } - oldBMarks.push(state.bMarks[nextLine]); - oldBSCount.push(state.bsCount[nextLine]); - oldTShift.push(state.tShift[nextLine]); - oldSCount.push(state.sCount[nextLine]); - state.sCount[nextLine] = -1; - } - const oldIndent = state.blkIndent; - state.blkIndent = 0; - const token_o = state.push("blockquote_open", "blockquote", 1); - token_o.markup = ">"; - const lines = [startLine, 0]; - token_o.map = lines; - state.md.block.tokenize(state, startLine, nextLine); - const token_c = state.push("blockquote_close", "blockquote", -1); - token_c.markup = ">"; - state.lineMax = oldLineMax; - state.parentType = oldParentType; - lines[1] = state.line; - for (let i = 0; i < oldTShift.length; i++) { - state.bMarks[i + startLine] = oldBMarks[i]; - state.tShift[i + startLine] = oldTShift[i]; - state.sCount[i + startLine] = oldSCount[i]; - state.bsCount[i + startLine] = oldBSCount[i]; - } - state.blkIndent = oldIndent; - return true; -} -function hr(state, startLine, endLine, silent) { - const max = state.eMarks[startLine]; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - let pos = state.bMarks[startLine] + state.tShift[startLine]; - const marker = state.src.charCodeAt(pos++); - if (marker !== 42 && marker !== 45 && marker !== 95) return false; - let cnt = 1; - while (pos < max) { - const ch = state.src.charCodeAt(pos++); - if (ch !== marker && !isSpace(ch)) return false; - if (ch === marker) cnt++; - } - if (cnt < 3) return false; - if (silent) return true; - state.line = startLine + 1; - const token = state.push("hr", "hr", 0); - token.map = [startLine, state.line]; - token.markup = Array(cnt + 1).join(String.fromCharCode(marker)); - return true; -} -function skipBulletListMarker(state, startLine) { - const max = state.eMarks[startLine]; - let pos = state.bMarks[startLine] + state.tShift[startLine]; - const marker = state.src.charCodeAt(pos++); - if (marker !== 42 && marker !== 45 && marker !== 43) return -1; - if (pos < max) { - if (!isSpace(state.src.charCodeAt(pos))) return -1; - } - return pos; -} -function skipOrderedListMarker(state, startLine) { - const start = state.bMarks[startLine] + state.tShift[startLine]; - const max = state.eMarks[startLine]; - let pos = start; - if (pos + 1 >= max) return -1; - let ch = state.src.charCodeAt(pos++); - if (ch < 48 || ch > 57) return -1; - for (;;) { - if (pos >= max) return -1; - ch = state.src.charCodeAt(pos++); - if (ch >= 48 && ch <= 57) { - if (pos - start >= 10) return -1; - continue; - } - if (ch === 41 || ch === 46) break; - return -1; - } - if (pos < max) { - ch = state.src.charCodeAt(pos); - if (!isSpace(ch)) return -1; - } - return pos; -} -function markTightParagraphs(state, idx) { - const level = state.level + 2; - for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) - if (state.tokens[i].level === level && state.tokens[i].type === "paragraph_open") { - state.tokens[i + 2].hidden = true; - state.tokens[i].hidden = true; - i += 2; - } -} -function list(state, startLine, endLine, silent) { - let max, pos, start, token; - let nextLine = startLine; - let tight = true; - if (state.sCount[nextLine] - state.blkIndent >= 4) return false; - if ( - state.listIndent >= 0 && - state.sCount[nextLine] - state.listIndent >= 4 && - state.sCount[nextLine] < state.blkIndent - ) - return false; - let isTerminatingParagraph = false; - if (silent && state.parentType === "paragraph") { - if (state.sCount[nextLine] >= state.blkIndent) isTerminatingParagraph = true; - } - let isOrdered; - let markerValue; - let posAfterMarker; - if ((posAfterMarker = skipOrderedListMarker(state, nextLine)) >= 0) { - isOrdered = true; - start = state.bMarks[nextLine] + state.tShift[nextLine]; - markerValue = Number(state.src.slice(start, posAfterMarker - 1)); - if (isTerminatingParagraph && markerValue !== 1) return false; - } else if ((posAfterMarker = skipBulletListMarker(state, nextLine)) >= 0) isOrdered = false; - else return false; - if (isTerminatingParagraph) { - if (state.skipSpaces(posAfterMarker) >= state.eMarks[nextLine]) return false; - } - if (silent) return true; - const markerCharCode = state.src.charCodeAt(posAfterMarker - 1); - const listTokIdx = state.tokens.length; - if (isOrdered) { - token = state.push("ordered_list_open", "ol", 1); - if (markerValue !== 1) token.attrs = [["start", markerValue]]; - } else token = state.push("bullet_list_open", "ul", 1); - const listLines = [nextLine, 0]; - token.map = listLines; - token.markup = String.fromCharCode(markerCharCode); - let prevEmptyEnd = false; - const terminatorRules = state.md.block.ruler.getRules("list"); - const oldParentType = state.parentType; - state.parentType = "list"; - while (nextLine < endLine) { - pos = posAfterMarker; - max = state.eMarks[nextLine]; - const initial = - state.sCount[nextLine] + posAfterMarker - (state.bMarks[nextLine] + state.tShift[nextLine]); - let offset = initial; - while (pos < max) { - const ch = state.src.charCodeAt(pos); - if (ch === 9) offset += 4 - ((offset + state.bsCount[nextLine]) % 4); - else if (ch === 32) offset++; - else break; - pos++; - } - const contentStart = pos; - let indentAfterMarker; - if (contentStart >= max) indentAfterMarker = 1; - else indentAfterMarker = offset - initial; - if (indentAfterMarker > 4) indentAfterMarker = 1; - const indent = initial + indentAfterMarker; - token = state.push("list_item_open", "li", 1); - token.markup = String.fromCharCode(markerCharCode); - const itemLines = [nextLine, 0]; - token.map = itemLines; - if (isOrdered) token.info = state.src.slice(start, posAfterMarker - 1); - const oldTight = state.tight; - const oldTShift = state.tShift[nextLine]; - const oldSCount = state.sCount[nextLine]; - const oldListIndent = state.listIndent; - state.listIndent = state.blkIndent; - state.blkIndent = indent; - state.tight = true; - state.tShift[nextLine] = contentStart - state.bMarks[nextLine]; - state.sCount[nextLine] = offset; - if (contentStart >= max && state.isEmpty(nextLine + 1)) - state.line = Math.min(state.line + 2, endLine); - else state.md.block.tokenize(state, nextLine, endLine, true); - if (!state.tight || prevEmptyEnd) tight = false; - prevEmptyEnd = state.line - nextLine > 1 && state.isEmpty(state.line - 1); - state.blkIndent = state.listIndent; - state.listIndent = oldListIndent; - state.tShift[nextLine] = oldTShift; - state.sCount[nextLine] = oldSCount; - state.tight = oldTight; - token = state.push("list_item_close", "li", -1); - token.markup = String.fromCharCode(markerCharCode); - nextLine = state.line; - itemLines[1] = nextLine; - if (nextLine >= endLine) break; - if (state.sCount[nextLine] < state.blkIndent) break; - if (state.sCount[nextLine] - state.blkIndent >= 4) break; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - if (terminate) break; - if (isOrdered) { - posAfterMarker = skipOrderedListMarker(state, nextLine); - if (posAfterMarker < 0) break; - start = state.bMarks[nextLine] + state.tShift[nextLine]; - } else { - posAfterMarker = skipBulletListMarker(state, nextLine); - if (posAfterMarker < 0) break; - } - if (markerCharCode !== state.src.charCodeAt(posAfterMarker - 1)) break; - } - if (isOrdered) token = state.push("ordered_list_close", "ol", -1); - else token = state.push("bullet_list_close", "ul", -1); - token.markup = String.fromCharCode(markerCharCode); - listLines[1] = nextLine; - state.line = nextLine; - state.parentType = oldParentType; - if (tight) markTightParagraphs(state, listTokIdx); - return true; -} -function reference(state, startLine, _endLine, silent) { - let pos = state.bMarks[startLine] + state.tShift[startLine]; - let max = state.eMarks[startLine]; - let nextLine = startLine + 1; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - if (state.src.charCodeAt(pos) !== 91) return false; - function getNextLine(nextLine) { - const endLine = state.lineMax; - if (nextLine >= endLine || state.isEmpty(nextLine)) return null; - let isContinuation = false; - if (state.sCount[nextLine] - state.blkIndent > 3) isContinuation = true; - if (state.sCount[nextLine] < 0) isContinuation = true; - if (!isContinuation) { - const terminatorRules = state.md.block.ruler.getRules("reference"); - const oldParentType = state.parentType; - state.parentType = "reference"; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - state.parentType = oldParentType; - if (terminate) return null; - } - const pos = state.bMarks[nextLine] + state.tShift[nextLine]; - const max = state.eMarks[nextLine]; - return state.src.slice(pos, max + 1); - } - let str = state.src.slice(pos, max + 1); - max = str.length; - let labelEnd = -1; - for (pos = 1; pos < max; pos++) { - const ch = str.charCodeAt(pos); - if (ch === 91) return false; - else if (ch === 93) { - labelEnd = pos; - break; - } else if (ch === 10) { - const lineContent = getNextLine(nextLine); - if (lineContent !== null) { - str += lineContent; - max = str.length; - nextLine++; - } - } else if (ch === 92) { - pos++; - if (pos < max && str.charCodeAt(pos) === 10) { - const lineContent = getNextLine(nextLine); - if (lineContent !== null) { - str += lineContent; - max = str.length; - nextLine++; - } - } - } - } - if (labelEnd < 0 || str.charCodeAt(labelEnd + 1) !== 58) return false; - for (pos = labelEnd + 2; pos < max; pos++) { - const ch = str.charCodeAt(pos); - if (ch === 10) { - const lineContent = getNextLine(nextLine); - if (lineContent !== null) { - str += lineContent; - max = str.length; - nextLine++; - } - } else if (isSpace(ch)) { - } else break; - } - const destRes = state.md.helpers.parseLinkDestination(str, pos, max); - if (!destRes.ok) return false; - const href = state.md.normalizeLink(destRes.str); - if (!state.md.validateLink(href)) return false; - pos = destRes.pos; - const destEndPos = pos; - const destEndLineNo = nextLine; - const start = pos; - for (; pos < max; pos++) { - const ch = str.charCodeAt(pos); - if (ch === 10) { - const lineContent = getNextLine(nextLine); - if (lineContent !== null) { - str += lineContent; - max = str.length; - nextLine++; - } - } else if (isSpace(ch)) { - } else break; - } - let titleRes = state.md.helpers.parseLinkTitle(str, pos, max); - while (titleRes.can_continue) { - const lineContent = getNextLine(nextLine); - if (lineContent === null) break; - str += lineContent; - pos = max; - max = str.length; - nextLine++; - titleRes = state.md.helpers.parseLinkTitle(str, pos, max, titleRes); - } - let title; - if (pos < max && start !== pos && titleRes.ok) { - title = titleRes.str; - pos = titleRes.pos; - } else { - title = ""; - pos = destEndPos; - nextLine = destEndLineNo; - } - while (pos < max) { - if (!isSpace(str.charCodeAt(pos))) break; - pos++; - } - if (pos < max && str.charCodeAt(pos) !== 10) { - if (title) { - title = ""; - pos = destEndPos; - nextLine = destEndLineNo; - while (pos < max) { - if (!isSpace(str.charCodeAt(pos))) break; - pos++; - } - } - } - if (pos < max && str.charCodeAt(pos) !== 10) return false; - const label = normalizeReference(str.slice(1, labelEnd)); - if (!label) return false; - /* istanbul ignore if */ - if (silent) return true; - if (typeof state.env.references === "undefined") state.env.references = {}; - if (typeof state.env.references[label] === "undefined") - state.env.references[label] = { - title, - href, - }; - state.line = nextLine; - return true; -} -var html_blocks_default = [ - "address", - "article", - "aside", - "base", - "basefont", - "blockquote", - "body", - "caption", - "center", - "col", - "colgroup", - "dd", - "details", - "dialog", - "dir", - "div", - "dl", - "dt", - "fieldset", - "figcaption", - "figure", - "footer", - "form", - "frame", - "frameset", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "head", - "header", - "hr", - "html", - "iframe", - "legend", - "li", - "link", - "main", - "menu", - "menuitem", - "nav", - "noframes", - "ol", - "optgroup", - "option", - "p", - "param", - "search", - "section", - "summary", - "table", - "tbody", - "td", - "tfoot", - "th", - "thead", - "title", - "tr", - "track", - "ul", -]; -const open_tag = - "<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>"; -const HTML_TAG_RE = new RegExp( - "^(?:" + - open_tag + - "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>||<[?][\\s\\S]*?[?]>|]*>|)", -); -const HTML_OPEN_CLOSE_TAG_RE = new RegExp("^(?:" + open_tag + "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)"); -const HTML_SEQUENCES = [ - [/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true], - [/^/, true], - [/^<\?/, /\?>/, true], - [/^/, true], - [/^/, true], - [new RegExp("^|$))", "i"), /^$/, true], - [new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + "\\s*$"), /^$/, false], -]; -function html_block(state, startLine, endLine, silent) { - let pos = state.bMarks[startLine] + state.tShift[startLine]; - let max = state.eMarks[startLine]; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - if (!state.md.options.html) return false; - if (state.src.charCodeAt(pos) !== 60) return false; - let lineText = state.src.slice(pos, max); - let i = 0; - for (; i < HTML_SEQUENCES.length; i++) if (HTML_SEQUENCES[i][0].test(lineText)) break; - if (i === HTML_SEQUENCES.length) return false; - if (silent) return HTML_SEQUENCES[i][2]; - let nextLine = startLine + 1; - if (!HTML_SEQUENCES[i][1].test(lineText)) - for (; nextLine < endLine; nextLine++) { - if (state.sCount[nextLine] < state.blkIndent) break; - pos = state.bMarks[nextLine] + state.tShift[nextLine]; - max = state.eMarks[nextLine]; - lineText = state.src.slice(pos, max); - if (HTML_SEQUENCES[i][1].test(lineText)) { - if (lineText.length !== 0) nextLine++; - break; - } - } - state.line = nextLine; - const token = state.push("html_block", "", 0); - token.map = [startLine, nextLine]; - token.content = state.getLines(startLine, nextLine, state.blkIndent, true); - return true; -} -function heading(state, startLine, endLine, silent) { - let pos = state.bMarks[startLine] + state.tShift[startLine]; - let max = state.eMarks[startLine]; - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - let ch = state.src.charCodeAt(pos); - if (ch !== 35 || pos >= max) return false; - let level = 1; - ch = state.src.charCodeAt(++pos); - while (ch === 35 && pos < max && level <= 6) { - level++; - ch = state.src.charCodeAt(++pos); - } - if (level > 6 || (pos < max && !isSpace(ch))) return false; - if (silent) return true; - max = state.skipSpacesBack(max, pos); - const tmp = state.skipCharsBack(max, 35, pos); - if (tmp > pos && isSpace(state.src.charCodeAt(tmp - 1))) max = tmp; - state.line = startLine + 1; - const token_o = state.push("heading_open", "h" + String(level), 1); - token_o.markup = "########".slice(0, level); - token_o.map = [startLine, state.line]; - const token_i = state.push("inline", "", 0); - token_i.content = state.src.slice(pos, max).trim(); - token_i.map = [startLine, state.line]; - token_i.children = []; - const token_c = state.push("heading_close", "h" + String(level), -1); - token_c.markup = "########".slice(0, level); - return true; -} -function lheading(state, startLine, endLine) { - const terminatorRules = state.md.block.ruler.getRules("paragraph"); - if (state.sCount[startLine] - state.blkIndent >= 4) return false; - const oldParentType = state.parentType; - state.parentType = "paragraph"; - let level = 0; - let marker; - let nextLine = startLine + 1; - for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { - if (state.sCount[nextLine] - state.blkIndent > 3) continue; - if (state.sCount[nextLine] >= state.blkIndent) { - let pos = state.bMarks[nextLine] + state.tShift[nextLine]; - const max = state.eMarks[nextLine]; - if (pos < max) { - marker = state.src.charCodeAt(pos); - if (marker === 45 || marker === 61) { - pos = state.skipChars(pos, marker); - pos = state.skipSpaces(pos); - if (pos >= max) { - level = marker === 61 ? 1 : 2; - break; - } - } - } - } - if (state.sCount[nextLine] < 0) continue; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - if (terminate) break; - } - if (!level) return false; - const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); - state.line = nextLine + 1; - const token_o = state.push("heading_open", "h" + String(level), 1); - token_o.markup = String.fromCharCode(marker); - token_o.map = [startLine, state.line]; - const token_i = state.push("inline", "", 0); - token_i.content = content; - token_i.map = [startLine, state.line - 1]; - token_i.children = []; - const token_c = state.push("heading_close", "h" + String(level), -1); - token_c.markup = String.fromCharCode(marker); - state.parentType = oldParentType; - return true; -} -function paragraph(state, startLine, endLine) { - const terminatorRules = state.md.block.ruler.getRules("paragraph"); - const oldParentType = state.parentType; - let nextLine = startLine + 1; - state.parentType = "paragraph"; - for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { - if (state.sCount[nextLine] - state.blkIndent > 3) continue; - if (state.sCount[nextLine] < 0) continue; - let terminate = false; - for (let i = 0, l = terminatorRules.length; i < l; i++) - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true; - break; - } - if (terminate) break; - } - const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); - state.line = nextLine; - const token_o = state.push("paragraph_open", "p", 1); - token_o.map = [startLine, state.line]; - const token_i = state.push("inline", "", 0); - token_i.content = content; - token_i.map = [startLine, state.line]; - token_i.children = []; - state.push("paragraph_close", "p", -1); - state.parentType = oldParentType; - return true; -} -/** internal - * class ParserBlock - * - * Block-level tokenizer. - **/ -const _rules$1 = [ - ["table", table, ["paragraph", "reference"]], - ["code", code], - ["fence", fence, ["paragraph", "reference", "blockquote", "list"]], - ["blockquote", blockquote, ["paragraph", "reference", "blockquote", "list"]], - ["hr", hr, ["paragraph", "reference", "blockquote", "list"]], - ["list", list, ["paragraph", "reference", "blockquote"]], - ["reference", reference], - ["html_block", html_block, ["paragraph", "reference", "blockquote"]], - ["heading", heading, ["paragraph", "reference", "blockquote"]], - ["lheading", lheading], - ["paragraph", paragraph], -]; -/** - * new ParserBlock() - **/ -function ParserBlock() { - /** - * ParserBlock#ruler -> Ruler - * - * [[Ruler]] instance. Keep configuration of block rules. - **/ - this.ruler = new Ruler(); - for (let i = 0; i < _rules$1.length; i++) - this.ruler.push(_rules$1[i][0], _rules$1[i][1], { alt: (_rules$1[i][2] || []).slice() }); -} -ParserBlock.prototype.tokenize = function (state, startLine, endLine) { - const rules = this.ruler.getRules(""); - const len = rules.length; - const maxNesting = state.md.options.maxNesting; - let line = startLine; - let hasEmptyLines = false; - while (line < endLine) { - state.line = line = state.skipEmptyLines(line); - if (line >= endLine) break; - if (state.sCount[line] < state.blkIndent) break; - if (state.level >= maxNesting) { - state.line = endLine; - break; - } - const prevLine = state.line; - let ok = false; - for (let i = 0; i < len; i++) { - ok = rules[i](state, line, endLine, false); - if (ok) { - if (prevLine >= state.line) throw new Error("block rule didn't increment state.line"); - break; - } - } - if (!ok) throw new Error("none of the block rules matched"); - state.tight = !hasEmptyLines; - if (state.isEmpty(state.line - 1)) hasEmptyLines = true; - line = state.line; - if (line < endLine && state.isEmpty(line)) { - hasEmptyLines = true; - line++; - state.line = line; - } - } -}; -/** - * ParserBlock.parse(str, md, env, outTokens) - * - * Process input string and push block tokens into `outTokens` - **/ -ParserBlock.prototype.parse = function (src, md, env, outTokens) { - if (!src) return; - const state = new this.State(src, md, env, outTokens); - this.tokenize(state, state.line, state.lineMax); -}; -ParserBlock.prototype.State = StateBlock; -function StateInline(src, md, env, outTokens) { - this.src = src; - this.env = env; - this.md = md; - this.tokens = outTokens; - this.tokens_meta = Array(outTokens.length); - this.pos = 0; - this.posMax = this.src.length; - this.level = 0; - this.pending = ""; - this.pendingLevel = 0; - this.cache = {}; - this.delimiters = []; - this._prev_delimiters = []; - this.backticks = {}; - this.backticksScanned = false; - this.linkLevel = 0; -} -StateInline.prototype.pushPending = function () { - const token = new Token("text", "", 0); - token.content = this.pending; - token.level = this.pendingLevel; - this.tokens.push(token); - this.pending = ""; - return token; -}; -StateInline.prototype.push = function (type, tag, nesting) { - if (this.pending) this.pushPending(); - const token = new Token(type, tag, nesting); - let token_meta = null; - if (nesting < 0) { - this.level--; - this.delimiters = this._prev_delimiters.pop(); - } - token.level = this.level; - if (nesting > 0) { - this.level++; - this._prev_delimiters.push(this.delimiters); - this.delimiters = []; - token_meta = { delimiters: this.delimiters }; - } - this.pendingLevel = this.level; - this.tokens.push(token); - this.tokens_meta.push(token_meta); - return token; -}; -StateInline.prototype.scanDelims = function (start, canSplitWord) { - const max = this.posMax; - const marker = this.src.charCodeAt(start); - const lastChar = start > 0 ? this.src.charCodeAt(start - 1) : 32; - let pos = start; - while (pos < max && this.src.charCodeAt(pos) === marker) pos++; - const count = pos - start; - const nextChar = pos < max ? this.src.charCodeAt(pos) : 32; - const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); - const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); - const isLastWhiteSpace = isWhiteSpace(lastChar); - const isNextWhiteSpace = isWhiteSpace(nextChar); - const left_flanking = - !isNextWhiteSpace && (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar); - const right_flanking = - !isLastWhiteSpace && (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar); - return { - can_open: left_flanking && (canSplitWord || !right_flanking || isLastPunctChar), - can_close: right_flanking && (canSplitWord || !left_flanking || isNextPunctChar), - length: count, - }; -}; -StateInline.prototype.Token = Token; -function isTerminatorChar(ch) { - switch (ch) { - case 10: - case 33: - case 35: - case 36: - case 37: - case 38: - case 42: - case 43: - case 45: - case 58: - case 60: - case 61: - case 62: - case 64: - case 91: - case 92: - case 93: - case 94: - case 95: - case 96: - case 123: - case 125: - case 126: - return true; - default: - return false; - } -} -function text(state, silent) { - let pos = state.pos; - while (pos < state.posMax && !isTerminatorChar(state.src.charCodeAt(pos))) pos++; - if (pos === state.pos) return false; - if (!silent) state.pending += state.src.slice(state.pos, pos); - state.pos = pos; - return true; -} -const SCHEME_RE = /(?:^|[^a-z0-9.+-])([a-z][a-z0-9.+-]*)$/i; -function linkify(state, silent) { - if (!state.md.options.linkify) return false; - if (state.linkLevel > 0) return false; - const pos = state.pos; - const max = state.posMax; - if (pos + 3 > max) return false; - if (state.src.charCodeAt(pos) !== 58) return false; - if (state.src.charCodeAt(pos + 1) !== 47) return false; - if (state.src.charCodeAt(pos + 2) !== 47) return false; - const match = state.pending.match(SCHEME_RE); - if (!match) return false; - const proto = match[1]; - const link = state.md.linkify.matchAtStart(state.src.slice(pos - proto.length)); - if (!link) return false; - let url = link.url; - if (url.length <= proto.length) return false; - let urlEnd = url.length; - while (urlEnd > 0 && url.charCodeAt(urlEnd - 1) === 42) urlEnd--; - if (urlEnd !== url.length) url = url.slice(0, urlEnd); - const fullUrl = state.md.normalizeLink(url); - if (!state.md.validateLink(fullUrl)) return false; - if (!silent) { - state.pending = state.pending.slice(0, -proto.length); - const token_o = state.push("link_open", "a", 1); - token_o.attrs = [["href", fullUrl]]; - token_o.markup = "linkify"; - token_o.info = "auto"; - const token_t = state.push("text", "", 0); - token_t.content = state.md.normalizeLinkText(url); - const token_c = state.push("link_close", "a", -1); - token_c.markup = "linkify"; - token_c.info = "auto"; - } - state.pos += url.length - proto.length; - return true; -} -function newline(state, silent) { - let pos = state.pos; - if (state.src.charCodeAt(pos) !== 10) return false; - const pmax = state.pending.length - 1; - const max = state.posMax; - if (!silent) - if (pmax >= 0 && state.pending.charCodeAt(pmax) === 32) - if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 32) { - let ws = pmax - 1; - while (ws >= 1 && state.pending.charCodeAt(ws - 1) === 32) ws--; - state.pending = state.pending.slice(0, ws); - state.push("hardbreak", "br", 0); - } else { - state.pending = state.pending.slice(0, -1); - state.push("softbreak", "br", 0); - } - else state.push("softbreak", "br", 0); - pos++; - while (pos < max && isSpace(state.src.charCodeAt(pos))) pos++; - state.pos = pos; - return true; -} -const ESCAPED = []; -for (let i = 0; i < 256; i++) ESCAPED.push(0); -"\\!\"#$%&'()*+,./:;<=>?@[]^_`{|}~-".split("").forEach(function (ch) { - ESCAPED[ch.charCodeAt(0)] = 1; -}); -function escape(state, silent) { - let pos = state.pos; - const max = state.posMax; - if (state.src.charCodeAt(pos) !== 92) return false; - pos++; - if (pos >= max) return false; - let ch1 = state.src.charCodeAt(pos); - if (ch1 === 10) { - if (!silent) state.push("hardbreak", "br", 0); - pos++; - while (pos < max) { - ch1 = state.src.charCodeAt(pos); - if (!isSpace(ch1)) break; - pos++; - } - state.pos = pos; - return true; - } - let escapedStr = state.src[pos]; - if (ch1 >= 55296 && ch1 <= 56319 && pos + 1 < max) { - const ch2 = state.src.charCodeAt(pos + 1); - if (ch2 >= 56320 && ch2 <= 57343) { - escapedStr += state.src[pos + 1]; - pos++; - } - } - const origStr = "\\" + escapedStr; - if (!silent) { - const token = state.push("text_special", "", 0); - if (ch1 < 256 && ESCAPED[ch1] !== 0) token.content = escapedStr; - else token.content = origStr; - token.markup = origStr; - token.info = "escape"; - } - state.pos = pos + 1; - return true; -} -function backtick(state, silent) { - let pos = state.pos; - if (state.src.charCodeAt(pos) !== 96) return false; - const start = pos; - pos++; - const max = state.posMax; - while (pos < max && state.src.charCodeAt(pos) === 96) pos++; - const marker = state.src.slice(start, pos); - const openerLength = marker.length; - if (state.backticksScanned && (state.backticks[openerLength] || 0) <= start) { - if (!silent) state.pending += marker; - state.pos += openerLength; - return true; - } - let matchEnd = pos; - let matchStart; - while ((matchStart = state.src.indexOf("`", matchEnd)) !== -1) { - matchEnd = matchStart + 1; - while (matchEnd < max && state.src.charCodeAt(matchEnd) === 96) matchEnd++; - const closerLength = matchEnd - matchStart; - if (closerLength === openerLength) { - if (!silent) { - const token = state.push("code_inline", "code", 0); - token.markup = marker; - token.content = state.src - .slice(pos, matchStart) - .replace(/\n/g, " ") - .replace(/^ (.+) $/, "$1"); - } - state.pos = matchEnd; - return true; - } - state.backticks[closerLength] = matchStart; - } - state.backticksScanned = true; - if (!silent) state.pending += marker; - state.pos += openerLength; - return true; -} -function strikethrough_tokenize(state, silent) { - const start = state.pos; - const marker = state.src.charCodeAt(start); - if (silent) return false; - if (marker !== 126) return false; - const scanned = state.scanDelims(state.pos, true); - let len = scanned.length; - const ch = String.fromCharCode(marker); - if (len < 2) return false; - let token; - if (len % 2) { - token = state.push("text", "", 0); - token.content = ch; - len--; - } - for (let i = 0; i < len; i += 2) { - token = state.push("text", "", 0); - token.content = ch + ch; - state.delimiters.push({ - marker, - length: 0, - token: state.tokens.length - 1, - end: -1, - open: scanned.can_open, - close: scanned.can_close, - }); - } - state.pos += scanned.length; - return true; -} -function postProcess$1(state, delimiters) { - let token; - const loneMarkers = []; - const max = delimiters.length; - for (let i = 0; i < max; i++) { - const startDelim = delimiters[i]; - if (startDelim.marker !== 126) continue; - if (startDelim.end === -1) continue; - const endDelim = delimiters[startDelim.end]; - token = state.tokens[startDelim.token]; - token.type = "s_open"; - token.tag = "s"; - token.nesting = 1; - token.markup = "~~"; - token.content = ""; - token = state.tokens[endDelim.token]; - token.type = "s_close"; - token.tag = "s"; - token.nesting = -1; - token.markup = "~~"; - token.content = ""; - if ( - state.tokens[endDelim.token - 1].type === "text" && - state.tokens[endDelim.token - 1].content === "~" - ) - loneMarkers.push(endDelim.token - 1); - } - while (loneMarkers.length) { - const i = loneMarkers.pop(); - let j = i + 1; - while (j < state.tokens.length && state.tokens[j].type === "s_close") j++; - j--; - if (i !== j) { - token = state.tokens[j]; - state.tokens[j] = state.tokens[i]; - state.tokens[i] = token; - } - } -} -function strikethrough_postProcess(state) { - const tokens_meta = state.tokens_meta; - const max = state.tokens_meta.length; - postProcess$1(state, state.delimiters); - for (let curr = 0; curr < max; curr++) - if (tokens_meta[curr] && tokens_meta[curr].delimiters) - postProcess$1(state, tokens_meta[curr].delimiters); -} -var strikethrough_default = { - tokenize: strikethrough_tokenize, - postProcess: strikethrough_postProcess, -}; -function emphasis_tokenize(state, silent) { - const start = state.pos; - const marker = state.src.charCodeAt(start); - if (silent) return false; - if (marker !== 95 && marker !== 42) return false; - const scanned = state.scanDelims(state.pos, marker === 42); - for (let i = 0; i < scanned.length; i++) { - const token = state.push("text", "", 0); - token.content = String.fromCharCode(marker); - state.delimiters.push({ - marker, - length: scanned.length, - token: state.tokens.length - 1, - end: -1, - open: scanned.can_open, - close: scanned.can_close, - }); - } - state.pos += scanned.length; - return true; -} -function postProcess(state, delimiters) { - const max = delimiters.length; - for (let i = max - 1; i >= 0; i--) { - const startDelim = delimiters[i]; - if (startDelim.marker !== 95 && startDelim.marker !== 42) continue; - if (startDelim.end === -1) continue; - const endDelim = delimiters[startDelim.end]; - const isStrong = - i > 0 && - delimiters[i - 1].end === startDelim.end + 1 && - delimiters[i - 1].marker === startDelim.marker && - delimiters[i - 1].token === startDelim.token - 1 && - delimiters[startDelim.end + 1].token === endDelim.token + 1; - const ch = String.fromCharCode(startDelim.marker); - const token_o = state.tokens[startDelim.token]; - token_o.type = isStrong ? "strong_open" : "em_open"; - token_o.tag = isStrong ? "strong" : "em"; - token_o.nesting = 1; - token_o.markup = isStrong ? ch + ch : ch; - token_o.content = ""; - const token_c = state.tokens[endDelim.token]; - token_c.type = isStrong ? "strong_close" : "em_close"; - token_c.tag = isStrong ? "strong" : "em"; - token_c.nesting = -1; - token_c.markup = isStrong ? ch + ch : ch; - token_c.content = ""; - if (isStrong) { - state.tokens[delimiters[i - 1].token].content = ""; - state.tokens[delimiters[startDelim.end + 1].token].content = ""; - i--; - } - } -} -function emphasis_post_process(state) { - const tokens_meta = state.tokens_meta; - const max = state.tokens_meta.length; - postProcess(state, state.delimiters); - for (let curr = 0; curr < max; curr++) - if (tokens_meta[curr] && tokens_meta[curr].delimiters) - postProcess(state, tokens_meta[curr].delimiters); -} -var emphasis_default = { - tokenize: emphasis_tokenize, - postProcess: emphasis_post_process, -}; -function link(state, silent) { - let code, label, res, ref; - let href = ""; - let title = ""; - let start = state.pos; - let parseReference = true; - if (state.src.charCodeAt(state.pos) !== 91) return false; - const oldPos = state.pos; - const max = state.posMax; - const labelStart = state.pos + 1; - const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true); - if (labelEnd < 0) return false; - let pos = labelEnd + 1; - if (pos < max && state.src.charCodeAt(pos) === 40) { - parseReference = false; - pos++; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - if (pos >= max) return false; - start = pos; - res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); - if (res.ok) { - href = state.md.normalizeLink(res.str); - if (state.md.validateLink(href)) pos = res.pos; - else href = ""; - start = pos; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); - if (pos < max && start !== pos && res.ok) { - title = res.str; - pos = res.pos; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - } - } - if (pos >= max || state.src.charCodeAt(pos) !== 41) parseReference = true; - pos++; - } - if (parseReference) { - if (typeof state.env.references === "undefined") return false; - if (pos < max && state.src.charCodeAt(pos) === 91) { - start = pos + 1; - pos = state.md.helpers.parseLinkLabel(state, pos); - if (pos >= 0) label = state.src.slice(start, pos++); - else pos = labelEnd + 1; - } else pos = labelEnd + 1; - if (!label) label = state.src.slice(labelStart, labelEnd); - ref = state.env.references[normalizeReference(label)]; - if (!ref) { - state.pos = oldPos; - return false; - } - href = ref.href; - title = ref.title; - } - if (!silent) { - state.pos = labelStart; - state.posMax = labelEnd; - const token_o = state.push("link_open", "a", 1); - const attrs = [["href", href]]; - token_o.attrs = attrs; - if (title) attrs.push(["title", title]); - state.linkLevel++; - state.md.inline.tokenize(state); - state.linkLevel--; - state.push("link_close", "a", -1); - } - state.pos = pos; - state.posMax = max; - return true; -} -function image(state, silent) { - let code, content, label, pos, ref, res, title, start; - let href = ""; - const oldPos = state.pos; - const max = state.posMax; - if (state.src.charCodeAt(state.pos) !== 33) return false; - if (state.src.charCodeAt(state.pos + 1) !== 91) return false; - const labelStart = state.pos + 2; - const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false); - if (labelEnd < 0) return false; - pos = labelEnd + 1; - if (pos < max && state.src.charCodeAt(pos) === 40) { - pos++; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - if (pos >= max) return false; - start = pos; - res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); - if (res.ok) { - href = state.md.normalizeLink(res.str); - if (state.md.validateLink(href)) pos = res.pos; - else href = ""; - } - start = pos; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); - if (pos < max && start !== pos && res.ok) { - title = res.str; - pos = res.pos; - for (; pos < max; pos++) { - code = state.src.charCodeAt(pos); - if (!isSpace(code) && code !== 10) break; - } - } else title = ""; - if (pos >= max || state.src.charCodeAt(pos) !== 41) { - state.pos = oldPos; - return false; - } - pos++; - } else { - if (typeof state.env.references === "undefined") return false; - if (pos < max && state.src.charCodeAt(pos) === 91) { - start = pos + 1; - pos = state.md.helpers.parseLinkLabel(state, pos); - if (pos >= 0) label = state.src.slice(start, pos++); - else pos = labelEnd + 1; - } else pos = labelEnd + 1; - if (!label) label = state.src.slice(labelStart, labelEnd); - ref = state.env.references[normalizeReference(label)]; - if (!ref) { - state.pos = oldPos; - return false; - } - href = ref.href; - title = ref.title; - } - if (!silent) { - content = state.src.slice(labelStart, labelEnd); - const tokens = []; - state.md.inline.parse(content, state.md, state.env, tokens); - const token = state.push("image", "img", 0); - const attrs = [ - ["src", href], - ["alt", ""], - ]; - token.attrs = attrs; - token.children = tokens; - token.content = content; - if (title) attrs.push(["title", title]); - } - state.pos = pos; - state.posMax = max; - return true; -} -const EMAIL_RE = - /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/; -const AUTOLINK_RE = /^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/; -function autolink(state, silent) { - let pos = state.pos; - if (state.src.charCodeAt(pos) !== 60) return false; - const start = state.pos; - const max = state.posMax; - for (;;) { - if (++pos >= max) return false; - const ch = state.src.charCodeAt(pos); - if (ch === 60) return false; - if (ch === 62) break; - } - const url = state.src.slice(start + 1, pos); - if (AUTOLINK_RE.test(url)) { - const fullUrl = state.md.normalizeLink(url); - if (!state.md.validateLink(fullUrl)) return false; - if (!silent) { - const token_o = state.push("link_open", "a", 1); - token_o.attrs = [["href", fullUrl]]; - token_o.markup = "autolink"; - token_o.info = "auto"; - const token_t = state.push("text", "", 0); - token_t.content = state.md.normalizeLinkText(url); - const token_c = state.push("link_close", "a", -1); - token_c.markup = "autolink"; - token_c.info = "auto"; - } - state.pos += url.length + 2; - return true; - } - if (EMAIL_RE.test(url)) { - const fullUrl = state.md.normalizeLink("mailto:" + url); - if (!state.md.validateLink(fullUrl)) return false; - if (!silent) { - const token_o = state.push("link_open", "a", 1); - token_o.attrs = [["href", fullUrl]]; - token_o.markup = "autolink"; - token_o.info = "auto"; - const token_t = state.push("text", "", 0); - token_t.content = state.md.normalizeLinkText(url); - const token_c = state.push("link_close", "a", -1); - token_c.markup = "autolink"; - token_c.info = "auto"; - } - state.pos += url.length + 2; - return true; - } - return false; -} -function isLinkOpen(str) { - return /^\s]/i.test(str); -} -function isLinkClose(str) { - return /^<\/a\s*>/i.test(str); -} -function isLetter(ch) { - const lc = ch | 32; - return lc >= 97 && lc <= 122; -} -function html_inline(state, silent) { - if (!state.md.options.html) return false; - const max = state.posMax; - const pos = state.pos; - if (state.src.charCodeAt(pos) !== 60 || pos + 2 >= max) return false; - const ch = state.src.charCodeAt(pos + 1); - if (ch !== 33 && ch !== 63 && ch !== 47 && !isLetter(ch)) return false; - const match = state.src.slice(pos).match(HTML_TAG_RE); - if (!match) return false; - if (!silent) { - const token = state.push("html_inline", "", 0); - token.content = match[0]; - if (isLinkOpen(token.content)) state.linkLevel++; - if (isLinkClose(token.content)) state.linkLevel--; - } - state.pos += match[0].length; - return true; -} -const DIGITAL_RE = /^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i; -const NAMED_RE = /^&([a-z][a-z0-9]{1,31});/i; -function entity(state, silent) { - const pos = state.pos; - const max = state.posMax; - if (state.src.charCodeAt(pos) !== 38) return false; - if (pos + 1 >= max) return false; - if (state.src.charCodeAt(pos + 1) === 35) { - const match = state.src.slice(pos).match(DIGITAL_RE); - if (match) { - if (!silent) { - const code = - match[1][0].toLowerCase() === "x" - ? parseInt(match[1].slice(1), 16) - : parseInt(match[1], 10); - const token = state.push("text_special", "", 0); - token.content = isValidEntityCode(code) ? fromCodePoint(code) : fromCodePoint(65533); - token.markup = match[0]; - token.info = "entity"; - } - state.pos += match[0].length; - return true; - } - } else { - const match = state.src.slice(pos).match(NAMED_RE); - if (match) { - const decoded = decodeHTML(match[0]); - if (decoded !== match[0]) { - if (!silent) { - const token = state.push("text_special", "", 0); - token.content = decoded; - token.markup = match[0]; - token.info = "entity"; - } - state.pos += match[0].length; - return true; - } - } - } - return false; -} -function processDelimiters(delimiters) { - const openersBottom = {}; - const max = delimiters.length; - if (!max) return; - let headerIdx = 0; - let lastTokenIdx = -2; - const jumps = []; - for (let closerIdx = 0; closerIdx < max; closerIdx++) { - const closer = delimiters[closerIdx]; - jumps.push(0); - if (delimiters[headerIdx].marker !== closer.marker || lastTokenIdx !== closer.token - 1) - headerIdx = closerIdx; - lastTokenIdx = closer.token; - closer.length = closer.length || 0; - if (!closer.close) continue; - if (!openersBottom.hasOwnProperty(closer.marker)) - openersBottom[closer.marker] = [-1, -1, -1, -1, -1, -1]; - const minOpenerIdx = openersBottom[closer.marker][(closer.open ? 3 : 0) + (closer.length % 3)]; - let openerIdx = headerIdx - jumps[headerIdx] - 1; - let newMinOpenerIdx = openerIdx; - for (; openerIdx > minOpenerIdx; openerIdx -= jumps[openerIdx] + 1) { - const opener = delimiters[openerIdx]; - if (opener.marker !== closer.marker) continue; - if (opener.open && opener.end < 0) { - let isOddMatch = false; - if (opener.close || closer.open) { - if ((opener.length + closer.length) % 3 === 0) { - if (opener.length % 3 !== 0 || closer.length % 3 !== 0) isOddMatch = true; - } - } - if (!isOddMatch) { - const lastJump = - openerIdx > 0 && !delimiters[openerIdx - 1].open ? jumps[openerIdx - 1] + 1 : 0; - jumps[closerIdx] = closerIdx - openerIdx + lastJump; - jumps[openerIdx] = lastJump; - closer.open = false; - opener.end = closerIdx; - opener.close = false; - newMinOpenerIdx = -1; - lastTokenIdx = -2; - break; - } - } - } - if (newMinOpenerIdx !== -1) - openersBottom[closer.marker][(closer.open ? 3 : 0) + ((closer.length || 0) % 3)] = - newMinOpenerIdx; - } -} -function link_pairs(state) { - const tokens_meta = state.tokens_meta; - const max = state.tokens_meta.length; - processDelimiters(state.delimiters); - for (let curr = 0; curr < max; curr++) - if (tokens_meta[curr] && tokens_meta[curr].delimiters) - processDelimiters(tokens_meta[curr].delimiters); -} -function fragments_join(state) { - let curr, last; - let level = 0; - const tokens = state.tokens; - const max = state.tokens.length; - for (curr = last = 0; curr < max; curr++) { - if (tokens[curr].nesting < 0) level--; - tokens[curr].level = level; - if (tokens[curr].nesting > 0) level++; - if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") - tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; - else { - if (curr !== last) tokens[last] = tokens[curr]; - last++; - } - } - if (curr !== last) tokens.length = last; -} -/** internal - * class ParserInline - * - * Tokenizes paragraph content. - **/ -const _rules = [ - ["text", text], - ["linkify", linkify], - ["newline", newline], - ["escape", escape], - ["backticks", backtick], - ["strikethrough", strikethrough_default.tokenize], - ["emphasis", emphasis_default.tokenize], - ["link", link], - ["image", image], - ["autolink", autolink], - ["html_inline", html_inline], - ["entity", entity], -]; -const _rules2 = [ - ["balance_pairs", link_pairs], - ["strikethrough", strikethrough_default.postProcess], - ["emphasis", emphasis_default.postProcess], - ["fragments_join", fragments_join], -]; -/** - * new ParserInline() - **/ -function ParserInline() { - /** - * ParserInline#ruler -> Ruler - * - * [[Ruler]] instance. Keep configuration of inline rules. - **/ - this.ruler = new Ruler(); - for (let i = 0; i < _rules.length; i++) this.ruler.push(_rules[i][0], _rules[i][1]); - /** - * ParserInline#ruler2 -> Ruler - * - * [[Ruler]] instance. Second ruler used for post-processing - * (e.g. in emphasis-like rules). - **/ - this.ruler2 = new Ruler(); - for (let i = 0; i < _rules2.length; i++) this.ruler2.push(_rules2[i][0], _rules2[i][1]); -} -ParserInline.prototype.skipToken = function (state) { - const pos = state.pos; - const rules = this.ruler.getRules(""); - const len = rules.length; - const maxNesting = state.md.options.maxNesting; - const cache = state.cache; - if (typeof cache[pos] !== "undefined") { - state.pos = cache[pos]; - return; - } - let ok = false; - if (state.level < maxNesting) - for (let i = 0; i < len; i++) { - state.level++; - ok = rules[i](state, true); - state.level--; - if (ok) { - if (pos >= state.pos) throw new Error("inline rule didn't increment state.pos"); - break; - } - } - else state.pos = state.posMax; - if (!ok) state.pos++; - cache[pos] = state.pos; -}; -ParserInline.prototype.tokenize = function (state) { - const rules = this.ruler.getRules(""); - const len = rules.length; - const end = state.posMax; - const maxNesting = state.md.options.maxNesting; - while (state.pos < end) { - const prevPos = state.pos; - let ok = false; - if (state.level < maxNesting) - for (let i = 0; i < len; i++) { - ok = rules[i](state, false); - if (ok) { - if (prevPos >= state.pos) throw new Error("inline rule didn't increment state.pos"); - break; - } - } - if (ok) { - if (state.pos >= end) break; - continue; - } - state.pending += state.src[state.pos++]; - } - if (state.pending) state.pushPending(); -}; -/** - * ParserInline.parse(str, md, env, outTokens) - * - * Process input string and push inline tokens into `outTokens` - **/ -ParserInline.prototype.parse = function (str, md, env, outTokens) { - const state = new this.State(str, md, env, outTokens); - this.tokenize(state); - const rules = this.ruler2.getRules(""); - const len = rules.length; - for (let i = 0; i < len; i++) rules[i](state); -}; -ParserInline.prototype.State = StateInline; -function re_default(opts) { - const re = {}; - opts = opts || {}; - re.src_Any = regex_default$5.source; - re.src_Cc = regex_default$4.source; - re.src_Z = regex_default.source; - re.src_P = regex_default$2.source; - re.src_ZPCc = [re.src_Z, re.src_P, re.src_Cc].join("|"); - re.src_ZCc = [re.src_Z, re.src_Cc].join("|"); - const text_separators = "[><|]"; - re.src_pseudo_letter = "(?:(?!" + text_separators + "|" + re.src_ZPCc + ")" + re.src_Any + ")"; - re.src_ip4 = - "(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"; - re.src_auth = "(?:(?:(?!" + re.src_ZCc + "|[@/\\[\\]()]).)+@)?"; - re.src_port = "(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?"; - re.src_host_terminator = - "(?=$|" + - text_separators + - "|" + - re.src_ZPCc + - ")(?!" + - (opts["---"] ? "-(?!--)|" : "-|") + - "_|:\\d|\\.-|\\.(?!$|" + - re.src_ZPCc + - "))"; - re.src_path = - "(?:[/?#](?:(?!" + - re.src_ZCc + - "|[><|]|[()[\\]{}.,\"'?!\\-;]).|\\[(?:(?!" + - re.src_ZCc + - "|\\]).)*\\]|\\((?:(?!" + - re.src_ZCc + - "|[)]).)*\\)|\\{(?:(?!" + - re.src_ZCc + - '|[}]).)*\\}|\\"(?:(?!' + - re.src_ZCc + - '|["]).)+\\"|\\\'(?:(?!' + - re.src_ZCc + - "|[']).)+\\'|\\'(?=" + - re.src_pseudo_letter + - "|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!" + - re.src_ZCc + - "|[.]|$)|" + - (opts["---"] ? "\\-(?!--(?:[^-]|$))(?:-*)|" : "\\-+|") + - ",(?!" + - re.src_ZCc + - "|$)|;(?!" + - re.src_ZCc + - "|$)|\\!+(?!" + - re.src_ZCc + - "|[!]|$)|\\?(?!" + - re.src_ZCc + - "|[?]|$))+|\\/)?"; - re.src_email_name = '[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*'; - re.src_xn = "xn--[a-z0-9\\-]{1,59}"; - re.src_domain_root = "(?:" + re.src_xn + "|" + re.src_pseudo_letter + "{1,63})"; - re.src_domain = - "(?:" + - re.src_xn + - "|(?:" + - re.src_pseudo_letter + - ")|(?:" + - re.src_pseudo_letter + - "(?:-|" + - re.src_pseudo_letter + - "){0,61}" + - re.src_pseudo_letter + - "))"; - re.src_host = "(?:(?:(?:(?:" + re.src_domain + ")\\.)*" + re.src_domain + "))"; - re.tpl_host_fuzzy = "(?:" + re.src_ip4 + "|(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%)))"; - re.tpl_host_no_ip_fuzzy = "(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%))"; - re.src_host_strict = re.src_host + re.src_host_terminator; - re.tpl_host_fuzzy_strict = re.tpl_host_fuzzy + re.src_host_terminator; - re.src_host_port_strict = re.src_host + re.src_port + re.src_host_terminator; - re.tpl_host_port_fuzzy_strict = re.tpl_host_fuzzy + re.src_port + re.src_host_terminator; - re.tpl_host_port_no_ip_fuzzy_strict = - re.tpl_host_no_ip_fuzzy + re.src_port + re.src_host_terminator; - re.tpl_host_fuzzy_test = - "localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:" + re.src_ZPCc + "|>|$))"; - re.tpl_email_fuzzy = - "(^|" + - text_separators + - '|"|\\(|' + - re.src_ZCc + - ")(" + - re.src_email_name + - "@" + - re.tpl_host_fuzzy_strict + - ")"; - re.tpl_link_fuzzy = - "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + - re.src_ZPCc + - "))((?![$+<=>^`||])" + - re.tpl_host_port_fuzzy_strict + - re.src_path + - ")"; - re.tpl_link_no_ip_fuzzy = - "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + - re.src_ZPCc + - "))((?![$+<=>^`||])" + - re.tpl_host_port_no_ip_fuzzy_strict + - re.src_path + - ")"; - return re; -} -function assign(obj) { - Array.prototype.slice.call(arguments, 1).forEach(function (source) { - if (!source) return; - Object.keys(source).forEach(function (key) { - obj[key] = source[key]; - }); - }); - return obj; -} -function _class(obj) { - return Object.prototype.toString.call(obj); -} -function isString(obj) { - return _class(obj) === "[object String]"; -} -function isObject(obj) { - return _class(obj) === "[object Object]"; -} -function isRegExp(obj) { - return _class(obj) === "[object RegExp]"; -} -function isFunction(obj) { - return _class(obj) === "[object Function]"; -} -function escapeRE(str) { - return str.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); -} -const defaultOptions = { - fuzzyLink: true, - fuzzyEmail: true, - fuzzyIP: false, -}; -function isOptionsObj(obj) { - return Object.keys(obj || {}).reduce(function (acc, k) { - return acc || defaultOptions.hasOwnProperty(k); - }, false); -} -const defaultSchemas = { - "http:": { - validate: function (text, pos, self) { - const tail = text.slice(pos); - if (!self.re.http) - self.re.http = new RegExp( - "^\\/\\/" + self.re.src_auth + self.re.src_host_port_strict + self.re.src_path, - "i", - ); - if (self.re.http.test(tail)) return tail.match(self.re.http)[0].length; - return 0; - }, - }, - "https:": "http:", - "ftp:": "http:", - "//": { - validate: function (text, pos, self) { - const tail = text.slice(pos); - if (!self.re.no_http) - self.re.no_http = new RegExp( - "^" + - self.re.src_auth + - "(?:localhost|(?:(?:" + - self.re.src_domain + - ")\\.)+" + - self.re.src_domain_root + - ")" + - self.re.src_port + - self.re.src_host_terminator + - self.re.src_path, - "i", - ); - if (self.re.no_http.test(tail)) { - if (pos >= 3 && text[pos - 3] === ":") return 0; - if (pos >= 3 && text[pos - 3] === "/") return 0; - return tail.match(self.re.no_http)[0].length; - } - return 0; - }, - }, - "mailto:": { - validate: function (text, pos, self) { - const tail = text.slice(pos); - if (!self.re.mailto) - self.re.mailto = new RegExp( - "^" + self.re.src_email_name + "@" + self.re.src_host_strict, - "i", - ); - if (self.re.mailto.test(tail)) return tail.match(self.re.mailto)[0].length; - return 0; - }, - }, -}; -const tlds_2ch_src_re = - "a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]"; -const tlds_default = - "biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф".split("|"); -function resetScanCache(self) { - self.__index__ = -1; - self.__text_cache__ = ""; -} -function createValidator(re) { - return function (text, pos) { - const tail = text.slice(pos); - if (re.test(tail)) return tail.match(re)[0].length; - return 0; - }; -} -function createNormalizer() { - return function (match, self) { - self.normalize(match); - }; -} -function compile(self) { - const re = (self.re = re_default(self.__opts__)); - const tlds = self.__tlds__.slice(); - self.onCompile(); - if (!self.__tlds_replaced__) tlds.push(tlds_2ch_src_re); - tlds.push(re.src_xn); - re.src_tlds = tlds.join("|"); - function untpl(tpl) { - return tpl.replace("%TLDS%", re.src_tlds); - } - re.email_fuzzy = RegExp(untpl(re.tpl_email_fuzzy), "i"); - re.link_fuzzy = RegExp(untpl(re.tpl_link_fuzzy), "i"); - re.link_no_ip_fuzzy = RegExp(untpl(re.tpl_link_no_ip_fuzzy), "i"); - re.host_fuzzy_test = RegExp(untpl(re.tpl_host_fuzzy_test), "i"); - const aliases = []; - self.__compiled__ = {}; - function schemaError(name, val) { - throw new Error('(LinkifyIt) Invalid schema "' + name + '": ' + val); - } - Object.keys(self.__schemas__).forEach(function (name) { - const val = self.__schemas__[name]; - if (val === null) return; - const compiled = { - validate: null, - link: null, - }; - self.__compiled__[name] = compiled; - if (isObject(val)) { - if (isRegExp(val.validate)) compiled.validate = createValidator(val.validate); - else if (isFunction(val.validate)) compiled.validate = val.validate; - else schemaError(name, val); - if (isFunction(val.normalize)) compiled.normalize = val.normalize; - else if (!val.normalize) compiled.normalize = createNormalizer(); - else schemaError(name, val); - return; - } - if (isString(val)) { - aliases.push(name); - return; - } - schemaError(name, val); - }); - aliases.forEach(function (alias) { - if (!self.__compiled__[self.__schemas__[alias]]) return; - self.__compiled__[alias].validate = self.__compiled__[self.__schemas__[alias]].validate; - self.__compiled__[alias].normalize = self.__compiled__[self.__schemas__[alias]].normalize; - }); - self.__compiled__[""] = { - validate: null, - normalize: createNormalizer(), - }; - const slist = Object.keys(self.__compiled__) - .filter(function (name) { - return name.length > 0 && self.__compiled__[name]; - }) - .map(escapeRE) - .join("|"); - self.re.schema_test = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "i"); - self.re.schema_search = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "ig"); - self.re.schema_at_start = RegExp("^" + self.re.schema_search.source, "i"); - self.re.pretest = RegExp( - "(" + self.re.schema_test.source + ")|(" + self.re.host_fuzzy_test.source + ")|@", - "i", - ); - resetScanCache(self); -} -/** - * class Match - * - * Match result. Single element of array, returned by [[LinkifyIt#match]] - **/ -function Match(self, shift) { - const start = self.__index__; - const end = self.__last_index__; - const text = self.__text_cache__.slice(start, end); - /** - * Match#schema -> String - * - * Prefix (protocol) for matched string. - **/ - this.schema = self.__schema__.toLowerCase(); - /** - * Match#index -> Number - * - * First position of matched string. - **/ - this.index = start + shift; - /** - * Match#lastIndex -> Number - * - * Next position after matched string. - **/ - this.lastIndex = end + shift; - /** - * Match#raw -> String - * - * Matched string. - **/ - this.raw = text; - /** - * Match#text -> String - * - * Notmalized text of matched string. - **/ - this.text = text; - /** - * Match#url -> String - * - * Normalized url of matched string. - **/ - this.url = text; -} -function createMatch(self, shift) { - const match = new Match(self, shift); - self.__compiled__[match.schema].normalize(match, self); - return match; -} -/** - * class LinkifyIt - **/ -/** - * new LinkifyIt(schemas, options) - * - schemas (Object): Optional. Additional schemas to validate (prefix/validator) - * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } - * - * Creates new linkifier instance with optional additional schemas. - * Can be called without `new` keyword for convenience. - * - * By default understands: - * - * - `http(s)://...` , `ftp://...`, `mailto:...` & `//...` links - * - "fuzzy" links and emails (example.com, foo@bar.com). - * - * `schemas` is an object, where each key/value describes protocol/rule: - * - * - __key__ - link prefix (usually, protocol name with `:` at the end, `skype:` - * for example). `linkify-it` makes shure that prefix is not preceeded with - * alphanumeric char and symbols. Only whitespaces and punctuation allowed. - * - __value__ - rule to check tail after link prefix - * - _String_ - just alias to existing rule - * - _Object_ - * - _validate_ - validator function (should return matched length on success), - * or `RegExp`. - * - _normalize_ - optional function to normalize text & url of matched result - * (for example, for @twitter mentions). - * - * `options`: - * - * - __fuzzyLink__ - recognige URL-s without `http(s):` prefix. Default `true`. - * - __fuzzyIP__ - allow IPs in fuzzy links above. Can conflict with some texts - * like version numbers. Default `false`. - * - __fuzzyEmail__ - recognize emails without `mailto:` prefix. - * - **/ -function LinkifyIt(schemas, options) { - if (!(this instanceof LinkifyIt)) return new LinkifyIt(schemas, options); - if (!options) { - if (isOptionsObj(schemas)) { - options = schemas; - schemas = {}; - } - } - this.__opts__ = assign({}, defaultOptions, options); - this.__index__ = -1; - this.__last_index__ = -1; - this.__schema__ = ""; - this.__text_cache__ = ""; - this.__schemas__ = assign({}, defaultSchemas, schemas); - this.__compiled__ = {}; - this.__tlds__ = tlds_default; - this.__tlds_replaced__ = false; - this.re = {}; - compile(this); -} -/** chainable - * LinkifyIt#add(schema, definition) - * - schema (String): rule name (fixed pattern prefix) - * - definition (String|RegExp|Object): schema definition - * - * Add new rule definition. See constructor description for details. - **/ -LinkifyIt.prototype.add = function add(schema, definition) { - this.__schemas__[schema] = definition; - compile(this); - return this; -}; -/** chainable - * LinkifyIt#set(options) - * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } - * - * Set recognition options for links without schema. - **/ -LinkifyIt.prototype.set = function set(options) { - this.__opts__ = assign(this.__opts__, options); - return this; -}; -/** - * LinkifyIt#test(text) -> Boolean - * - * Searches linkifiable pattern and returns `true` on success or `false` on fail. - **/ -LinkifyIt.prototype.test = function test(text) { - this.__text_cache__ = text; - this.__index__ = -1; - if (!text.length) return false; - let m, ml, me, len, shift, next, re, tld_pos, at_pos; - if (this.re.schema_test.test(text)) { - re = this.re.schema_search; - re.lastIndex = 0; - while ((m = re.exec(text)) !== null) { - len = this.testSchemaAt(text, m[2], re.lastIndex); - if (len) { - this.__schema__ = m[2]; - this.__index__ = m.index + m[1].length; - this.__last_index__ = m.index + m[0].length + len; - break; - } - } - } - if (this.__opts__.fuzzyLink && this.__compiled__["http:"]) { - tld_pos = text.search(this.re.host_fuzzy_test); - if (tld_pos >= 0) { - if (this.__index__ < 0 || tld_pos < this.__index__) { - if ( - (ml = text.match( - this.__opts__.fuzzyIP ? this.re.link_fuzzy : this.re.link_no_ip_fuzzy, - )) !== null - ) { - shift = ml.index + ml[1].length; - if (this.__index__ < 0 || shift < this.__index__) { - this.__schema__ = ""; - this.__index__ = shift; - this.__last_index__ = ml.index + ml[0].length; - } - } - } - } - } - if (this.__opts__.fuzzyEmail && this.__compiled__["mailto:"]) { - at_pos = text.indexOf("@"); - if (at_pos >= 0) { - if ((me = text.match(this.re.email_fuzzy)) !== null) { - shift = me.index + me[1].length; - next = me.index + me[0].length; - if ( - this.__index__ < 0 || - shift < this.__index__ || - (shift === this.__index__ && next > this.__last_index__) - ) { - this.__schema__ = "mailto:"; - this.__index__ = shift; - this.__last_index__ = next; - } - } - } - } - return this.__index__ >= 0; -}; -/** - * LinkifyIt#pretest(text) -> Boolean - * - * Very quick check, that can give false positives. Returns true if link MAY BE - * can exists. Can be used for speed optimization, when you need to check that - * link NOT exists. - **/ -LinkifyIt.prototype.pretest = function pretest(text) { - return this.re.pretest.test(text); -}; -/** - * LinkifyIt#testSchemaAt(text, name, position) -> Number - * - text (String): text to scan - * - name (String): rule (schema) name - * - position (Number): text offset to check from - * - * Similar to [[LinkifyIt#test]] but checks only specific protocol tail exactly - * at given position. Returns length of found pattern (0 on fail). - **/ -LinkifyIt.prototype.testSchemaAt = function testSchemaAt(text, schema, pos) { - if (!this.__compiled__[schema.toLowerCase()]) return 0; - return this.__compiled__[schema.toLowerCase()].validate(text, pos, this); -}; -/** - * LinkifyIt#match(text) -> Array|null - * - * Returns array of found link descriptions or `null` on fail. We strongly - * recommend to use [[LinkifyIt#test]] first, for best speed. - * - * ##### Result match description - * - * - __schema__ - link schema, can be empty for fuzzy links, or `//` for - * protocol-neutral links. - * - __index__ - offset of matched text - * - __lastIndex__ - index of next char after mathch end - * - __raw__ - matched text - * - __text__ - normalized text - * - __url__ - link, generated from matched text - **/ -LinkifyIt.prototype.match = function match(text) { - const result = []; - let shift = 0; - if (this.__index__ >= 0 && this.__text_cache__ === text) { - result.push(createMatch(this, shift)); - shift = this.__last_index__; - } - let tail = shift ? text.slice(shift) : text; - while (this.test(tail)) { - result.push(createMatch(this, shift)); - tail = tail.slice(this.__last_index__); - shift += this.__last_index__; - } - if (result.length) return result; - return null; -}; -/** - * LinkifyIt#matchAtStart(text) -> Match|null - * - * Returns fully-formed (not fuzzy) link if it starts at the beginning - * of the string, and null otherwise. - **/ -LinkifyIt.prototype.matchAtStart = function matchAtStart(text) { - this.__text_cache__ = text; - this.__index__ = -1; - if (!text.length) return null; - const m = this.re.schema_at_start.exec(text); - if (!m) return null; - const len = this.testSchemaAt(text, m[2], m[0].length); - if (!len) return null; - this.__schema__ = m[2]; - this.__index__ = m.index + m[1].length; - this.__last_index__ = m.index + m[0].length + len; - return createMatch(this, 0); -}; -/** chainable - * LinkifyIt#tlds(list [, keepOld]) -> this - * - list (Array): list of tlds - * - keepOld (Boolean): merge with current list if `true` (`false` by default) - * - * Load (or merge) new tlds list. Those are user for fuzzy links (without prefix) - * to avoid false positives. By default this algorythm used: - * - * - hostname with any 2-letter root zones are ok. - * - biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф - * are ok. - * - encoded (`xn--...`) root zones are ok. - * - * If list is replaced, then exact match for 2-chars root zones will be checked. - **/ -LinkifyIt.prototype.tlds = function tlds(list, keepOld) { - list = Array.isArray(list) ? list : [list]; - if (!keepOld) { - this.__tlds__ = list.slice(); - this.__tlds_replaced__ = true; - compile(this); - return this; - } - this.__tlds__ = this.__tlds__ - .concat(list) - .sort() - .filter(function (el, idx, arr) { - return el !== arr[idx - 1]; - }) - .reverse(); - compile(this); - return this; -}; -/** - * LinkifyIt#normalize(match) - * - * Default normalizer (if schema does not define it's own). - **/ -LinkifyIt.prototype.normalize = function normalize(match) { - if (!match.schema) match.url = "http://" + match.url; - if (match.schema === "mailto:" && !/^mailto:/i.test(match.url)) match.url = "mailto:" + match.url; -}; -/** - * LinkifyIt#onCompile() - * - * Override to modify basic RegExp-s. - **/ -LinkifyIt.prototype.onCompile = function onCompile() {}; -/** Highest positive signed 32-bit float value */ -const maxInt = 2147483647; -/** Bootstring parameters */ -const base = 36; -const tMin = 1; -const tMax = 26; -const skew = 38; -const damp = 700; -const initialBias = 72; -const initialN = 128; -const delimiter = "-"; -/** Regular expressions */ -const regexPunycode = /^xn--/; -const regexNonASCII = /[^\0-\x7F]/; -const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; -/** Error messages */ -const errors = { - overflow: "Overflow: input needs wider integers to process", - "not-basic": "Illegal input >= 0x80 (not a basic code point)", - "invalid-input": "Invalid input", -}; -/** Convenience shortcuts */ -const baseMinusTMin = base - tMin; -const floor = Math.floor; -const stringFromCharCode = String.fromCharCode; -/** - * A generic error utility function. - * @private - * @param {String} type The error type. - * @returns {Error} Throws a `RangeError` with the applicable error message. - */ -function error(type) { - throw new RangeError(errors[type]); -} -/** - * A generic `Array#map` utility function. - * @private - * @param {Array} array The array to iterate over. - * @param {Function} callback The function that gets called for every array - * item. - * @returns {Array} A new array of values returned by the callback function. - */ -function map(array, callback) { - const result = []; - let length = array.length; - while (length--) result[length] = callback(array[length]); - return result; -} -/** - * A simple `Array#map`-like wrapper to work with domain name strings or email - * addresses. - * @private - * @param {String} domain The domain name or email address. - * @param {Function} callback The function that gets called for every - * character. - * @returns {String} A new string of characters returned by the callback - * function. - */ -function mapDomain(domain, callback) { - const parts = domain.split("@"); - let result = ""; - if (parts.length > 1) { - result = parts[0] + "@"; - domain = parts[1]; - } - domain = domain.replace(regexSeparators, "."); - const encoded = map(domain.split("."), callback).join("."); - return result + encoded; -} -/** - * Creates an array containing the numeric code points of each Unicode - * character in the string. While JavaScript uses UCS-2 internally, - * this function will convert a pair of surrogate halves (each of which - * UCS-2 exposes as separate characters) into a single code point, - * matching UTF-16. - * @see `punycode.ucs2.encode` - * @see - * @memberOf punycode.ucs2 - * @name decode - * @param {String} string The Unicode input string (UCS-2). - * @returns {Array} The new array of code points. - */ -function ucs2decode(string) { - const output = []; - let counter = 0; - const length = string.length; - while (counter < length) { - const value = string.charCodeAt(counter++); - if (value >= 55296 && value <= 56319 && counter < length) { - const extra = string.charCodeAt(counter++); - if ((extra & 64512) == 56320) output.push(((value & 1023) << 10) + (extra & 1023) + 65536); - else { - output.push(value); - counter--; - } - } else output.push(value); - } - return output; -} -/** - * Creates a string based on an array of numeric code points. - * @see `punycode.ucs2.decode` - * @memberOf punycode.ucs2 - * @name encode - * @param {Array} codePoints The array of numeric code points. - * @returns {String} The new Unicode string (UCS-2). - */ -const ucs2encode = (codePoints) => String.fromCodePoint(...codePoints); -/** - * Converts a basic code point into a digit/integer. - * @see `digitToBasic()` - * @private - * @param {Number} codePoint The basic numeric code point value. - * @returns {Number} The numeric value of a basic code point (for use in - * representing integers) in the range `0` to `base - 1`, or `base` if - * the code point does not represent a value. - */ -const basicToDigit = function (codePoint) { - if (codePoint >= 48 && codePoint < 58) return 26 + (codePoint - 48); - if (codePoint >= 65 && codePoint < 91) return codePoint - 65; - if (codePoint >= 97 && codePoint < 123) return codePoint - 97; - return base; -}; -/** - * Converts a digit/integer into a basic code point. - * @see `basicToDigit()` - * @private - * @param {Number} digit The numeric value of a basic code point. - * @returns {Number} The basic code point whose value (when used for - * representing integers) is `digit`, which needs to be in the range - * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is - * used; else, the lowercase form is used. The behavior is undefined - * if `flag` is non-zero and `digit` has no uppercase form. - */ -const digitToBasic = function (digit, flag) { - return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); -}; -/** - * Bias adaptation function as per section 3.4 of RFC 3492. - * https://tools.ietf.org/html/rfc3492#section-3.4 - * @private - */ -const adapt = function (delta, numPoints, firstTime) { - let k = 0; - delta = firstTime ? floor(delta / damp) : delta >> 1; - delta += floor(delta / numPoints); - for (; delta > (baseMinusTMin * tMax) >> 1; k += base) delta = floor(delta / baseMinusTMin); - return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)); -}; -/** - * Converts a Punycode string of ASCII-only symbols to a string of Unicode - * symbols. - * @memberOf punycode - * @param {String} input The Punycode string of ASCII-only symbols. - * @returns {String} The resulting string of Unicode symbols. - */ -const decode = function (input) { - const output = []; - const inputLength = input.length; - let i = 0; - let n = initialN; - let bias = initialBias; - let basic = input.lastIndexOf(delimiter); - if (basic < 0) basic = 0; - for (let j = 0; j < basic; ++j) { - if (input.charCodeAt(j) >= 128) error("not-basic"); - output.push(input.charCodeAt(j)); - } - for (let index = basic > 0 ? basic + 1 : 0; index < inputLength; ) { - const oldi = i; - for (let w = 1, k = base; ; k += base) { - if (index >= inputLength) error("invalid-input"); - const digit = basicToDigit(input.charCodeAt(index++)); - if (digit >= base) error("invalid-input"); - if (digit > floor((maxInt - i) / w)) error("overflow"); - i += digit * w; - const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; - if (digit < t) break; - const baseMinusT = base - t; - if (w > floor(maxInt / baseMinusT)) error("overflow"); - w *= baseMinusT; - } - const out = output.length + 1; - bias = adapt(i - oldi, out, oldi == 0); - if (floor(i / out) > maxInt - n) error("overflow"); - n += floor(i / out); - i %= out; - output.splice(i++, 0, n); - } - return String.fromCodePoint(...output); -}; -/** - * Converts a string of Unicode symbols (e.g. a domain name label) to a - * Punycode string of ASCII-only symbols. - * @memberOf punycode - * @param {String} input The string of Unicode symbols. - * @returns {String} The resulting Punycode string of ASCII-only symbols. - */ -const encode = function (input) { - const output = []; - input = ucs2decode(input); - const inputLength = input.length; - let n = initialN; - let delta = 0; - let bias = initialBias; - for (const currentValue of input) - if (currentValue < 128) output.push(stringFromCharCode(currentValue)); - const basicLength = output.length; - let handledCPCount = basicLength; - if (basicLength) output.push(delimiter); - while (handledCPCount < inputLength) { - let m = maxInt; - for (const currentValue of input) if (currentValue >= n && currentValue < m) m = currentValue; - const handledCPCountPlusOne = handledCPCount + 1; - if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) error("overflow"); - delta += (m - n) * handledCPCountPlusOne; - n = m; - for (const currentValue of input) { - if (currentValue < n && ++delta > maxInt) error("overflow"); - if (currentValue === n) { - let q = delta; - for (let k = base; ; k += base) { - const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; - if (q < t) break; - const qMinusT = q - t; - const baseMinusT = base - t; - output.push(stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0))); - q = floor(qMinusT / baseMinusT); - } - output.push(stringFromCharCode(digitToBasic(q, 0))); - bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength); - delta = 0; - ++handledCPCount; - } - } - ++delta; - ++n; - } - return output.join(""); -}; -/** - * Converts a Punycode string representing a domain name or an email address - * to Unicode. Only the Punycoded parts of the input will be converted, i.e. - * it doesn't matter if you call it on a string that has already been - * converted to Unicode. - * @memberOf punycode - * @param {String} input The Punycoded domain name or email address to - * convert to Unicode. - * @returns {String} The Unicode representation of the given Punycode - * string. - */ -const toUnicode = function (input) { - return mapDomain(input, function (string) { - return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string; - }); -}; -/** - * Converts a Unicode string representing a domain name or an email address to - * Punycode. Only the non-ASCII parts of the domain name will be converted, - * i.e. it doesn't matter if you call it with a domain that's already in - * ASCII. - * @memberOf punycode - * @param {String} input The domain name or email address to convert, as a - * Unicode string. - * @returns {String} The Punycode representation of the given domain name or - * email address. - */ -const toASCII = function (input) { - return mapDomain(input, function (string) { - return regexNonASCII.test(string) ? "xn--" + encode(string) : string; - }); -}; -/** Define the public API */ -const punycode = { - version: "2.3.1", - ucs2: { - decode: ucs2decode, - encode: ucs2encode, - }, - decode: decode, - encode: encode, - toASCII: toASCII, - toUnicode: toUnicode, -}; -const config = { - default: { - options: { - html: false, - xhtmlOut: false, - breaks: false, - langPrefix: "language-", - linkify: false, - typographer: false, - quotes: "“”‘’", - highlight: null, - maxNesting: 100, - }, - components: { - core: {}, - block: {}, - inline: {}, - }, - }, - zero: { - options: { - html: false, - xhtmlOut: false, - breaks: false, - langPrefix: "language-", - linkify: false, - typographer: false, - quotes: "“”‘’", - highlight: null, - maxNesting: 20, - }, - components: { - core: { rules: ["normalize", "block", "inline", "text_join"] }, - block: { rules: ["paragraph"] }, - inline: { - rules: ["text"], - rules2: ["balance_pairs", "fragments_join"], - }, - }, - }, - commonmark: { - options: { - html: true, - xhtmlOut: true, - breaks: false, - langPrefix: "language-", - linkify: false, - typographer: false, - quotes: "“”‘’", - highlight: null, - maxNesting: 20, - }, - components: { - core: { rules: ["normalize", "block", "inline", "text_join"] }, - block: { - rules: [ - "blockquote", - "code", - "fence", - "heading", - "hr", - "html_block", - "lheading", - "list", - "reference", - "paragraph", - ], - }, - inline: { - rules: [ - "autolink", - "backticks", - "emphasis", - "entity", - "escape", - "html_inline", - "image", - "link", - "newline", - "text", - ], - rules2: ["balance_pairs", "emphasis", "fragments_join"], - }, - }, - }, -}; -const BAD_PROTO_RE = /^(vbscript|javascript|file|data):/; -const GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/; -function validateLink(url) { - const str = url.trim().toLowerCase(); - return BAD_PROTO_RE.test(str) ? GOOD_DATA_RE.test(str) : true; -} -const RECODE_HOSTNAME_FOR = ["http:", "https:", "mailto:"]; -function normalizeLink(url) { - const parsed = urlParse(url, true); - if (parsed.hostname) { - if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) - try { - parsed.hostname = punycode.toASCII(parsed.hostname); - } catch (er) {} - } - return encode$2(format(parsed)); -} -function normalizeLinkText(url) { - const parsed = urlParse(url, true); - if (parsed.hostname) { - if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) - try { - parsed.hostname = punycode.toUnicode(parsed.hostname); - } catch (er) {} - } - return decode$2(format(parsed), decode$2.defaultChars + "%"); -} -/** - * class MarkdownIt - * - * Main parser/renderer class. - * - * ##### Usage - * - * ```javascript - * // node.js, "classic" way: - * var MarkdownIt = require('markdown-it'), - * md = new MarkdownIt(); - * var result = md.render('# markdown-it rulezz!'); - * - * // node.js, the same, but with sugar: - * var md = require('markdown-it')(); - * var result = md.render('# markdown-it rulezz!'); - * - * // browser without AMD, added to "window" on script load - * // Note, there are no dash. - * var md = window.markdownit(); - * var result = md.render('# markdown-it rulezz!'); - * ``` - * - * Single line rendering, without paragraph wrap: - * - * ```javascript - * var md = require('markdown-it')(); - * var result = md.renderInline('__markdown-it__ rulezz!'); - * ``` - **/ -/** - * new MarkdownIt([presetName, options]) - * - presetName (String): optional, `commonmark` / `zero` - * - options (Object) - * - * Creates parser instanse with given config. Can be called without `new`. - * - * ##### presetName - * - * MarkdownIt provides named presets as a convenience to quickly - * enable/disable active syntax rules and options for common use cases. - * - * - ["commonmark"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.mjs) - - * configures parser to strict [CommonMark](http://commonmark.org/) mode. - * - [default](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/default.mjs) - - * similar to GFM, used when no preset name given. Enables all available rules, - * but still without html, typographer & autolinker. - * - ["zero"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/zero.mjs) - - * all rules disabled. Useful to quickly setup your config via `.enable()`. - * For example, when you need only `bold` and `italic` markup and nothing else. - * - * ##### options: - * - * - __html__ - `false`. Set `true` to enable HTML tags in source. Be careful! - * That's not safe! You may need external sanitizer to protect output from XSS. - * It's better to extend features via plugins, instead of enabling HTML. - * - __xhtmlOut__ - `false`. Set `true` to add '/' when closing single tags - * (`
`). This is needed only for full CommonMark compatibility. In real - * world you will need HTML output. - * - __breaks__ - `false`. Set `true` to convert `\n` in paragraphs into `
`. - * - __langPrefix__ - `language-`. CSS language class prefix for fenced blocks. - * Can be useful for external highlighters. - * - __linkify__ - `false`. Set `true` to autoconvert URL-like text to links. - * - __typographer__ - `false`. Set `true` to enable [some language-neutral - * replacement](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/replacements.mjs) + - * quotes beautification (smartquotes). - * - __quotes__ - `“”‘’`, String or Array. Double + single quotes replacement - * pairs, when typographer enabled and smartquotes on. For example, you can - * use `'«»„“'` for Russian, `'„“‚‘'` for German, and - * `['«\xA0', '\xA0»', '‹\xA0', '\xA0›']` for French (including nbsp). - * - __highlight__ - `null`. Highlighter function for fenced code blocks. - * Highlighter `function (str, lang)` should return escaped HTML. It can also - * return empty string if the source was not changed and should be escaped - * externaly. If result starts with ` or ``): - * - * ```javascript - * var hljs = require('highlight.js') // https://highlightjs.org/ - * - * // Actual default values - * var md = require('markdown-it')({ - * highlight: function (str, lang) { - * if (lang && hljs.getLanguage(lang)) { - * try { - * return '
' +
- *                hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
- *                '
'; - * } catch (__) {} - * } - * - * return '
' + md.utils.escapeHtml(str) + '
'; - * } - * }); - * ``` - * - **/ -function MarkdownIt(presetName, options) { - if (!(this instanceof MarkdownIt)) return new MarkdownIt(presetName, options); - if (!options) { - if (!isString$1(presetName)) { - options = presetName || {}; - presetName = "default"; - } - } - /** - * MarkdownIt#inline -> ParserInline - * - * Instance of [[ParserInline]]. You may need it to add new rules when - * writing plugins. For simple rules control use [[MarkdownIt.disable]] and - * [[MarkdownIt.enable]]. - **/ - this.inline = new ParserInline(); - /** - * MarkdownIt#block -> ParserBlock - * - * Instance of [[ParserBlock]]. You may need it to add new rules when - * writing plugins. For simple rules control use [[MarkdownIt.disable]] and - * [[MarkdownIt.enable]]. - **/ - this.block = new ParserBlock(); - /** - * MarkdownIt#core -> Core - * - * Instance of [[Core]] chain executor. You may need it to add new rules when - * writing plugins. For simple rules control use [[MarkdownIt.disable]] and - * [[MarkdownIt.enable]]. - **/ - this.core = new Core(); - /** - * MarkdownIt#renderer -> Renderer - * - * Instance of [[Renderer]]. Use it to modify output look. Or to add rendering - * rules for new token types, generated by plugins. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')(); - * - * function myToken(tokens, idx, options, env, self) { - * //... - * return result; - * }; - * - * md.renderer.rules['my_token'] = myToken - * ``` - * - * See [[Renderer]] docs and [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs). - **/ - this.renderer = new Renderer(); - /** - * MarkdownIt#linkify -> LinkifyIt - * - * [linkify-it](https://github.com/markdown-it/linkify-it) instance. - * Used by [linkify](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/linkify.mjs) - * rule. - **/ - this.linkify = new LinkifyIt(); - /** - * MarkdownIt#validateLink(url) -> Boolean - * - * Link validation function. CommonMark allows too much in links. By default - * we disable `javascript:`, `vbscript:`, `file:` schemas, and almost all `data:...` schemas - * except some embedded image types. - * - * You can change this behaviour: - * - * ```javascript - * var md = require('markdown-it')(); - * // enable everything - * md.validateLink = function () { return true; } - * ``` - **/ - this.validateLink = validateLink; - /** - * MarkdownIt#normalizeLink(url) -> String - * - * Function used to encode link url to a machine-readable format, - * which includes url-encoding, punycode, etc. - **/ - this.normalizeLink = normalizeLink; - /** - * MarkdownIt#normalizeLinkText(url) -> String - * - * Function used to decode link url to a human-readable format` - **/ - this.normalizeLinkText = normalizeLinkText; - /** - * MarkdownIt#utils -> utils - * - * Assorted utility functions, useful to write plugins. See details - * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/common/utils.mjs). - **/ - this.utils = utils_exports; - /** - * MarkdownIt#helpers -> helpers - * - * Link components parser functions, useful to write plugins. See details - * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/helpers). - **/ - this.helpers = assign$1({}, helpers_exports); - this.options = {}; - this.configure(presetName); - if (options) this.set(options); -} -/** chainable - * MarkdownIt.set(options) - * - * Set parser options (in the same format as in constructor). Probably, you - * will never need it, but you can change options after constructor call. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')() - * .set({ html: true, breaks: true }) - * .set({ typographer, true }); - * ``` - * - * __Note:__ To achieve the best possible performance, don't modify a - * `markdown-it` instance options on the fly. If you need multiple configurations - * it's best to create multiple instances and initialize each with separate - * config. - **/ -MarkdownIt.prototype.set = function (options) { - assign$1(this.options, options); - return this; -}; -/** chainable, internal - * MarkdownIt.configure(presets) - * - * Batch load of all options and compenent settings. This is internal method, - * and you probably will not need it. But if you will - see available presets - * and data structure [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets) - * - * We strongly recommend to use presets instead of direct config loads. That - * will give better compatibility with next versions. - **/ -MarkdownIt.prototype.configure = function (presets) { - const self = this; - if (isString$1(presets)) { - const presetName = presets; - presets = config[presetName]; - if (!presets) throw new Error('Wrong `markdown-it` preset "' + presetName + '", check name'); - } - if (!presets) throw new Error("Wrong `markdown-it` preset, can't be empty"); - if (presets.options) self.set(presets.options); - if (presets.components) - Object.keys(presets.components).forEach(function (name) { - if (presets.components[name].rules) - self[name].ruler.enableOnly(presets.components[name].rules); - if (presets.components[name].rules2) - self[name].ruler2.enableOnly(presets.components[name].rules2); - }); - return this; -}; -/** chainable - * MarkdownIt.enable(list, ignoreInvalid) - * - list (String|Array): rule name or list of rule names to enable - * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. - * - * Enable list or rules. It will automatically find appropriate components, - * containing rules with given names. If rule not found, and `ignoreInvalid` - * not set - throws exception. - * - * ##### Example - * - * ```javascript - * var md = require('markdown-it')() - * .enable(['sub', 'sup']) - * .disable('smartquotes'); - * ``` - **/ -MarkdownIt.prototype.enable = function (list, ignoreInvalid) { - let result = []; - if (!Array.isArray(list)) list = [list]; - ["core", "block", "inline"].forEach(function (chain) { - result = result.concat(this[chain].ruler.enable(list, true)); - }, this); - result = result.concat(this.inline.ruler2.enable(list, true)); - const missed = list.filter(function (name) { - return result.indexOf(name) < 0; - }); - if (missed.length && !ignoreInvalid) - throw new Error("MarkdownIt. Failed to enable unknown rule(s): " + missed); - return this; -}; -/** chainable - * MarkdownIt.disable(list, ignoreInvalid) - * - list (String|Array): rule name or list of rule names to disable. - * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. - * - * The same as [[MarkdownIt.enable]], but turn specified rules off. - **/ -MarkdownIt.prototype.disable = function (list, ignoreInvalid) { - let result = []; - if (!Array.isArray(list)) list = [list]; - ["core", "block", "inline"].forEach(function (chain) { - result = result.concat(this[chain].ruler.disable(list, true)); - }, this); - result = result.concat(this.inline.ruler2.disable(list, true)); - const missed = list.filter(function (name) { - return result.indexOf(name) < 0; - }); - if (missed.length && !ignoreInvalid) - throw new Error("MarkdownIt. Failed to disable unknown rule(s): " + missed); - return this; -}; -/** chainable - * MarkdownIt.use(plugin, params) - * - * Load specified plugin with given params into current parser instance. - * It's just a sugar to call `plugin(md, params)` with curring. - * - * ##### Example - * - * ```javascript - * var iterator = require('markdown-it-for-inline'); - * var md = require('markdown-it')() - * .use(iterator, 'foo_replace', 'text', function (tokens, idx) { - * tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar'); - * }); - * ``` - **/ -MarkdownIt.prototype.use = function (plugin) { - const args = [this].concat(Array.prototype.slice.call(arguments, 1)); - plugin.apply(plugin, args); - return this; -}; -/** internal - * MarkdownIt.parse(src, env) -> Array - * - src (String): source string - * - env (Object): environment sandbox - * - * Parse input string and return list of block tokens (special token type - * "inline" will contain list of inline tokens). You should not call this - * method directly, until you write custom renderer (for example, to produce - * AST). - * - * `env` is used to pass data between "distributed" rules and return additional - * metadata like reference info, needed for the renderer. It also can be used to - * inject data in specific cases. Usually, you will be ok to pass `{}`, - * and then pass updated object to renderer. - **/ -MarkdownIt.prototype.parse = function (src, env) { - if (typeof src !== "string") throw new Error("Input data should be a String"); - const state = new this.core.State(src, this, env); - this.core.process(state); - return state.tokens; -}; -/** - * MarkdownIt.render(src [, env]) -> String - * - src (String): source string - * - env (Object): environment sandbox - * - * Render markdown string into html. It does all magic for you :). - * - * `env` can be used to inject additional metadata (`{}` by default). - * But you will not need it with high probability. See also comment - * in [[MarkdownIt.parse]]. - **/ -MarkdownIt.prototype.render = function (src, env) { - env = env || {}; - return this.renderer.render(this.parse(src, env), this.options, env); -}; -/** internal - * MarkdownIt.parseInline(src, env) -> Array - * - src (String): source string - * - env (Object): environment sandbox - * - * The same as [[MarkdownIt.parse]] but skip all block rules. It returns the - * block tokens list with the single `inline` element, containing parsed inline - * tokens in `children` property. Also updates `env` object. - **/ -MarkdownIt.prototype.parseInline = function (src, env) { - const state = new this.core.State(src, this, env); - state.inlineMode = true; - this.core.process(state); - return state.tokens; -}; -/** - * MarkdownIt.renderInline(src [, env]) -> String - * - src (String): source string - * - env (Object): environment sandbox - * - * Similar to [[MarkdownIt.render]] but for single paragraph content. Result - * will NOT be wrapped into `

` tags. - **/ -MarkdownIt.prototype.renderInline = function (src, env) { - env = env || {}; - return this.renderer.render(this.parseInline(src, env), this.options, env); -}; -/** - * This is only safe for (and intended to be used for) text node positions. If - * you are using attribute position, then this is only safe if the attribute - * value is surrounded by double-quotes, and is unsafe otherwise (because the - * value could break out of the attribute value and e.g. add another attribute). - */ -function escapeNodeText(str) { - const frag = document.createElement("div"); - D(b`${str}`, frag); - return frag.innerHTML.replaceAll(//gim, ""); -} -var MarkdownDirective = class extends i$5 { - #markdownIt = MarkdownIt({ - highlight: (str, lang) => { - switch (lang) { - case "html": { - const iframe = document.createElement("iframe"); - iframe.classList.add("html-view"); - iframe.srcdoc = str; - iframe.sandbox = ""; - return iframe.innerHTML; - } - default: - return escapeNodeText(str); - } - }, - }); - #lastValue = null; - #lastTagClassMap = null; - update(_part, [value, tagClassMap]) { - if (this.#lastValue === value && JSON.stringify(tagClassMap) === this.#lastTagClassMap) - return E; - this.#lastValue = value; - this.#lastTagClassMap = JSON.stringify(tagClassMap); - return this.render(value, tagClassMap); - } - #originalClassMap = /* @__PURE__ */ new Map(); - #applyTagClassMap(tagClassMap) { - Object.entries(tagClassMap).forEach(([tag]) => { - let tokenName; - switch (tag) { - case "p": - tokenName = "paragraph"; - break; - case "h1": - case "h2": - case "h3": - case "h4": - case "h5": - case "h6": - tokenName = "heading"; - break; - case "ul": - tokenName = "bullet_list"; - break; - case "ol": - tokenName = "ordered_list"; - break; - case "li": - tokenName = "list_item"; - break; - case "a": - tokenName = "link"; - break; - case "strong": - tokenName = "strong"; - break; - case "em": - tokenName = "em"; - break; - } - if (!tokenName) return; - const key = `${tokenName}_open`; - this.#markdownIt.renderer.rules[key] = (tokens, idx, options, _env, self) => { - const token = tokens[idx]; - const tokenClasses = tagClassMap[token.tag] ?? []; - for (const clazz of tokenClasses) token.attrJoin("class", clazz); - return self.renderToken(tokens, idx, options); - }; - }); - } - #unapplyTagClassMap() { - for (const [key] of this.#originalClassMap) delete this.#markdownIt.renderer.rules[key]; - this.#originalClassMap.clear(); - } - /** - * Renders the markdown string to HTML using MarkdownIt. - * - * Note: MarkdownIt doesn't enable HTML in its output, so we render the - * value directly without further sanitization. - * @see https://github.com/markdown-it/markdown-it/blob/master/docs/security.md - */ - render(value, tagClassMap) { - if (tagClassMap) this.#applyTagClassMap(tagClassMap); - const htmlString = this.#markdownIt.render(value); - this.#unapplyTagClassMap(); - return o(htmlString); - } -}; -const markdown = e$10(MarkdownDirective); -MarkdownIt(); -var __esDecorate$1 = function ( - ctor, - descriptorIn, - decorators, - contextIn, - initializers, - extraInitializers, -) { - function accept(f) { - if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); - return f; - } - var kind = contextIn.kind, - key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; - var descriptor = - descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _, - done = false; - for (var i = decorators.length - 1; i >= 0; i--) { - var context = {}; - for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context.access[p] = contextIn.access[p]; - context.addInitializer = function (f) { - if (done) throw new TypeError("Cannot add initializers after decoration has completed"); - extraInitializers.push(accept(f || null)); - }; - var result = (0, decorators[i])( - kind === "accessor" - ? { - get: descriptor.get, - set: descriptor.set, - } - : descriptor[key], - context, - ); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if ((_ = accept(result.get))) descriptor.get = _; - if ((_ = accept(result.set))) descriptor.set = _; - if ((_ = accept(result.init))) initializers.unshift(_); - } else if ((_ = accept(result))) - if (kind === "field") initializers.unshift(_); - else descriptor[key] = _; - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; -}; -var __runInitializers$1 = function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i = 0; i < initializers.length; i++) - value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); - return useValue ? value : void 0; -}; -(() => { - let _classDecorators = [t$1("a2ui-text")]; - let _classDescriptor; - let _classExtraInitializers = []; - let _classThis; - let _classSuper = Root; - let _text_decorators; - let _text_initializers = []; - let _text_extraInitializers = []; - let _usageHint_decorators; - let _usageHint_initializers = []; - let _usageHint_extraInitializers = []; - var Text = class extends _classSuper { - static { - _classThis = this; - } - static { - const _metadata = - typeof Symbol === "function" && Symbol.metadata - ? Object.create(_classSuper[Symbol.metadata] ?? null) - : void 0; - _text_decorators = [n$6()]; - _usageHint_decorators = [ - n$6({ - reflect: true, - attribute: "usage-hint", - }), - ]; - __esDecorate$1( - this, - null, - _text_decorators, - { - kind: "accessor", - name: "text", - static: false, - private: false, - access: { - has: (obj) => "text" in obj, - get: (obj) => obj.text, - set: (obj, value) => { - obj.text = value; - }, - }, - metadata: _metadata, - }, - _text_initializers, - _text_extraInitializers, - ); - __esDecorate$1( - this, - null, - _usageHint_decorators, - { - kind: "accessor", - name: "usageHint", - static: false, - private: false, - access: { - has: (obj) => "usageHint" in obj, - get: (obj) => obj.usageHint, - set: (obj, value) => { - obj.usageHint = value; - }, - }, - metadata: _metadata, - }, - _usageHint_initializers, - _usageHint_extraInitializers, - ); - __esDecorate$1( - null, - (_classDescriptor = { value: _classThis }), - _classDecorators, - { - kind: "class", - name: _classThis.name, - metadata: _metadata, - }, - null, - _classExtraInitializers, - ); - Text = _classThis = _classDescriptor.value; - if (_metadata) - Object.defineProperty(_classThis, Symbol.metadata, { - enumerable: true, - configurable: true, - writable: true, - value: _metadata, - }); - } - #text_accessor_storage = __runInitializers$1(this, _text_initializers, null); - get text() { - return this.#text_accessor_storage; - } - set text(value) { - this.#text_accessor_storage = value; - } - #usageHint_accessor_storage = - (__runInitializers$1(this, _text_extraInitializers), - __runInitializers$1(this, _usageHint_initializers, null)); - get usageHint() { - return this.#usageHint_accessor_storage; - } - set usageHint(value) { - this.#usageHint_accessor_storage = value; - } - static { - this.styles = [ - structuralStyles, - i$9` - :host { - display: block; - flex: var(--weight); - } - - h1, - h2, - h3, - h4, - h5 { - line-height: inherit; - font: inherit; - } - `, - ]; - } - #renderText() { - let textValue = null; - if (this.text && typeof this.text === "object") { - if ("literalString" in this.text && this.text.literalString) - textValue = this.text.literalString; - else if ("literal" in this.text && this.text.literal !== void 0) - textValue = this.text.literal; - else if (this.text && "path" in this.text && this.text.path) { - if (!this.processor || !this.component) return b`(no model)`; - const value = this.processor.getData( - this.component, - this.text.path, - this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID, - ); - if (value !== null && value !== void 0) textValue = value.toString(); - } - } - if (textValue === null || textValue === void 0) return b`(empty)`; - let markdownText = textValue; - switch (this.usageHint) { - case "h1": - markdownText = `# ${markdownText}`; - break; - case "h2": - markdownText = `## ${markdownText}`; - break; - case "h3": - markdownText = `### ${markdownText}`; - break; - case "h4": - markdownText = `#### ${markdownText}`; - break; - case "h5": - markdownText = `##### ${markdownText}`; - break; - case "caption": - markdownText = `*${markdownText}*`; - break; - default: - break; - } - return b`${markdown(markdownText, appendToAll(this.theme.markdown, ["ol", "ul", "li"], {}))}`; - } - #areHintedStyles(styles) { - if (typeof styles !== "object") return false; - if (Array.isArray(styles)) return false; - if (!styles) return false; - return ["h1", "h2", "h3", "h4", "h5", "h6", "caption", "body"].every((v) => v in styles); - } - #getAdditionalStyles() { - let additionalStyles = {}; - const styles = this.theme.additionalStyles?.Text; - if (!styles) return additionalStyles; - if (this.#areHintedStyles(styles)) additionalStyles = styles[this.usageHint ?? "body"]; - else additionalStyles = styles; - return additionalStyles; - } - render() { - return b`

- ${this.#renderText()} -
`; - } - constructor() { - super(...arguments); - __runInitializers$1(this, _usageHint_extraInitializers); - } - static { - __runInitializers$1(_classThis, _classExtraInitializers); - } - }; - return _classThis; -})(); -var __esDecorate = function ( - ctor, - descriptorIn, - decorators, - contextIn, - initializers, - extraInitializers, -) { - function accept(f) { - if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); - return f; - } - var kind = contextIn.kind, - key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; - var descriptor = - descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _, - done = false; - for (var i = decorators.length - 1; i >= 0; i--) { - var context = {}; - for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context.access[p] = contextIn.access[p]; - context.addInitializer = function (f) { - if (done) throw new TypeError("Cannot add initializers after decoration has completed"); - extraInitializers.push(accept(f || null)); - }; - var result = (0, decorators[i])( - kind === "accessor" - ? { - get: descriptor.get, - set: descriptor.set, - } - : descriptor[key], - context, - ); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if ((_ = accept(result.get))) descriptor.get = _; - if ((_ = accept(result.set))) descriptor.set = _; - if ((_ = accept(result.init))) initializers.unshift(_); - } else if ((_ = accept(result))) - if (kind === "field") initializers.unshift(_); - else descriptor[key] = _; - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; -}; -var __runInitializers = function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i = 0; i < initializers.length; i++) - value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); - return useValue ? value : void 0; -}; -(() => { - let _classDecorators = [t$1("a2ui-video")]; - let _classDescriptor; - let _classExtraInitializers = []; - let _classThis; - let _classSuper = Root; - let _url_decorators; - let _url_initializers = []; - let _url_extraInitializers = []; - var Video = class extends _classSuper { - static { - _classThis = this; - } - static { - const _metadata = - typeof Symbol === "function" && Symbol.metadata - ? Object.create(_classSuper[Symbol.metadata] ?? null) - : void 0; - _url_decorators = [n$6()]; - __esDecorate( - this, - null, - _url_decorators, - { - kind: "accessor", - name: "url", - static: false, - private: false, - access: { - has: (obj) => "url" in obj, - get: (obj) => obj.url, - set: (obj, value) => { - obj.url = value; - }, - }, - metadata: _metadata, - }, - _url_initializers, - _url_extraInitializers, - ); - __esDecorate( - null, - (_classDescriptor = { value: _classThis }), - _classDecorators, - { - kind: "class", - name: _classThis.name, - metadata: _metadata, - }, - null, - _classExtraInitializers, - ); - Video = _classThis = _classDescriptor.value; - if (_metadata) - Object.defineProperty(_classThis, Symbol.metadata, { - enumerable: true, - configurable: true, - writable: true, - value: _metadata, - }); - } - #url_accessor_storage = __runInitializers(this, _url_initializers, null); - get url() { - return this.#url_accessor_storage; - } - set url(value) { - this.#url_accessor_storage = value; - } - static { - this.styles = [ - structuralStyles, - i$9` - * { - box-sizing: border-box; - } - - :host { - display: block; - flex: var(--weight); - min-height: 0; - overflow: auto; - } - - video { - display: block; - width: 100%; - } - `, - ]; - } - #renderVideo() { - if (!this.url) return A; - if (this.url && typeof this.url === "object") { - if ("literalString" in this.url) return b`