diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e38cc1703a..34afa1bc61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,11 @@ Docs: https://docs.openclaw.ai - Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx. - Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55. - macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage. +- Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env. + +### Fixes + +- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. ## 2026.3.13 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 910b6db2b62..ee823da9cac 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -877,6 +877,7 @@ Time format in system prompt. Default: `auto` (OS preference). }, imageGenerationModel: { primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-3.1-flash-image-preview"], }, pdfModel: { primary: "anthropic/claude-opus-4-6", diff --git a/docs/help/testing.md b/docs/help/testing.md index ab63db23670..f3315fa6faa 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -360,6 +360,15 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Enable: `BYTEPLUS_API_KEY=... BYTEPLUS_LIVE_TEST=1 pnpm test:live src/agents/byteplus.live.test.ts` - Optional model override: `BYTEPLUS_CODING_MODEL=ark-code-latest` +## Google image generation live + +- Test: `src/image-generation/providers/google.live.test.ts` +- Enable: `GOOGLE_LIVE_TEST=1 pnpm test:live src/image-generation/providers/google.live.test.ts` +- Key source: `GEMINI_API_KEY` or `GOOGLE_API_KEY` +- Optional overrides: + - `GOOGLE_IMAGE_GENERATION_MODEL=gemini-3.1-flash-image-preview` + - `GOOGLE_IMAGE_BASE_URL=https://generativelanguage.googleapis.com/v1beta` + ## Docker runners (optional “works in Linux” checks) These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index 345c7b1ebd6..5cfc94ef3c0 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -88,7 +88,7 @@ Image generation follows the standard shape: 1. core defines `ImageGenerationProvider` 2. core exposes `registerImageGenerationProvider(...)` 3. core exposes `runtime.imageGeneration.generate(...)` -4. the `openai` plugin registers an OpenAI-backed implementation +4. the `openai` and `google` plugins register vendor-backed implementations 5. future vendors can register the same contract without changing channels/tools The config key is separate from vision-analysis routing: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 77ff383feb6..b0eec032bcf 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -116,8 +116,10 @@ Examples: speech + media-understanding + image-generation behavior - the bundled `elevenlabs` plugin owns ElevenLabs speech behavior - the bundled `microsoft` plugin owns Microsoft speech behavior -- the bundled `google`, `minimax`, `mistral`, `moonshot`, and `zai` plugins own - their media-understanding backends +- the bundled `google` plugin owns Google model-provider behavior plus Google + media-understanding + image-generation + web-search behavior +- the bundled `minimax`, `mistral`, `moonshot`, and `zai` plugins own their + media-understanding backends - the `voice-call` plugin is a feature plugin: it owns call transport, tools, CLI, routes, and runtime, but it consumes core TTS/STT capability instead of inventing a second speech stack diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index 1988c03ca26..c45ed85fb0b 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,43 +1,10 @@ -import { - buildChannelConfigSchema, - DiscordConfigSchema, - getChatChannelMeta, - type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/discord"; import { type ResolvedDiscordAccount } from "./accounts.js"; -import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase } from "./shared.js"; 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, + ...createDiscordPluginBase({ + setup: discordSetupAdapter, + }), }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 761ccb5f8b5..46679586665 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -10,12 +10,15 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeOutboundThreadId, +} from "openclaw/plugin-sdk/core"; +import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, - DiscordConfigSchema, getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -28,11 +31,6 @@ import { type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/discord"; -import { - buildAgentSessionKey, - resolveThreadSessionKeys, - type RoutePeer, -} from "openclaw/plugin-sdk/routing"; import { listDiscordAccountIds, resolveDiscordAccount, @@ -48,12 +46,12 @@ import { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; -import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -203,34 +201,13 @@ function parseDiscordExplicitTarget(raw: string) { } } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildDiscordBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; accountId?: string | null; peer: RoutePeer; }) { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "discord", - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey({ ...params, channel: "discord" }); } function resolveDiscordOutboundTargetKindHint(params: { @@ -300,11 +277,9 @@ function resolveDiscordOutboundSessionRoute(params: { } export const discordPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...meta, - }, - setupWizard: discordSetupWizard, + ...createDiscordPluginBase({ + setup: discordSetupAdapter, + }), pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), @@ -315,31 +290,6 @@ export const discordPlugin: ChannelPlugin = { ); }, }, - 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, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => diff --git a/extensions/discord/src/plugin-shared.ts b/extensions/discord/src/plugin-shared.ts deleted file mode 100644 index fd5fb029f7c..00000000000 --- a/extensions/discord/src/plugin-shared.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution"; -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, - type ResolvedDiscordAccount, -} from "./accounts.js"; -import { createDiscordSetupWizardProxy } from "./setup-core.js"; - -async function loadDiscordChannelRuntime() { - return await import("./channel.runtime.js"); -} - -export const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -export const discordConfigBase = createScopedChannelConfigBase({ - sectionKey: "discord", - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultDiscordAccountId, - clearBaseFields: ["token", "name"], -}); - -export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 7f4c1be29d3..4b807f10a65 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,10 +1,7 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { - applyAccountNameToChannelSection, - createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, + createEnvPatchedAccountSetupAdapter, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -74,71 +71,13 @@ export function parseDiscordAllowFromId(value: string): string | null { }); } -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 discordSetupAdapter: ChannelSetupAdapter = createEnvPatchedAccountSetupAdapter({ + channelKey: channel, + defaultAccountOnlyEnvError: "DISCORD_BOT_TOKEN can only be used for the default account.", + missingCredentialError: "Discord requires token (or --use-env).", + hasCredentials: (input) => Boolean(input.token), + buildPatch: (input) => (input.token ? { token: input.token } : {}), +}); export function createDiscordSetupWizardBase(handlers: { promptAllowFrom: NonNullable; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index be5a374d0fa..9c1ce7f5f1c 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,24 +1,12 @@ import { - DEFAULT_ACCOUNT_ID, - noteChannelLookupFailure, - noteChannelLookupSummary, type OpenClawConfig, - parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; -import { type ChannelSetupDmPolicy, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "./accounts.js"; +import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { resolveDiscordChannelAllowlist, @@ -26,7 +14,7 @@ import { } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { - discordSetupAdapter, + createDiscordSetupWizardBase, DISCORD_TOKEN_HELP_LINES, parseDiscordAllowFromId, setDiscordGuildChannelAllowlist, @@ -91,186 +79,49 @@ async function promptDiscordAllowFrom(params: { }); } -const discordDmPolicy: ChannelSetupDmPolicy = { - label: "Discord", - channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptDiscordAllowFrom, -}; +async function resolveDiscordGroupAllowlist(params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; +}) { + const token = + resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || + (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""); + if (!token || params.entries.length === 0) { + return params.entries.map((input) => ({ + input, + resolved: false, + })); + } + return await resolveDiscordChannelAllowlist({ + token, + entries: params.entries, + }); +} -export const discordSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "configured", - unconfiguredHint: "needs token", - configuredScore: 2, - unconfiguredScore: 1, - resolveConfigured: ({ cfg }) => - listDiscordAccountIds(cfg).some( - (accountId) => inspectDiscordAccount({ cfg, accountId }).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 === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - 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, - }; - }, +export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase(async () => ({ + discordSetupWizard: { + dmPolicy: { + promptAllowFrom: promptDiscordAllowFrom, }, - ], - groupAccess: { - label: "Discord channels", - placeholder: "My Server/#general, guildId/channelId, #support", - currentPolicy: ({ cfg, accountId }) => - resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - 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 }) => - Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), - setPolicy: ({ cfg, accountId, policy }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { groupPolicy: policy }, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const token = - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - if (!token || entries.length === 0) { - return resolved; - } - try { - resolved = await resolveDiscordChannelAllowlist({ - token, + groupAccess: { + resolveAllowlist: async ({ cfg, accountId, credentialValues, entries }) => + await resolveDiscordGroupAllowlist({ + cfg, + accountId, + credentialValues, entries, - }); - const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); - const resolvedGuilds = resolved.filter( - (entry) => entry.resolved && entry.guildId && !entry.channelId, - ); - const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [ - { - title: "Resolved channels", - values: resolvedChannels - .map((entry) => entry.channelId) - .filter((value): value is string => Boolean(value)), - }, - { - title: "Resolved guilds", - values: resolvedGuilds - .map((entry) => entry.guildId) - .filter((value): value is string => Boolean(value)), - }, - ], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - } - return resolved; + }), }, - applyAllowlist: ({ cfg, accountId, resolved }) => { - const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; - for (const entry of resolved as DiscordChannelResolution[]) { - const guildKey = - entry.guildId ?? - (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? - "*"; - const channelKey = - entry.channelId ?? - (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); - if (!channelKey && guildKey === "*") { - continue; - } - allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); - } - return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries); + allowFrom: { + resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => + await resolveDiscordAllowFromEntries({ + token: + resolveDiscordAccount({ cfg, accountId }).token || + (typeof credentialValues.token === "string" ? credentialValues.token : ""), + entries, + }), }, - }, - 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 }) => - await resolveDiscordAllowFromEntries({ - token: - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""), - entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - dmPolicy: discordDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; + } as ChannelSetupWizard, +})); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index d310d8183a9..87872051cbd 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -1,4 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildGoogleImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { GOOGLE_GEMINI_DEFAULT_MODEL, @@ -51,6 +52,7 @@ const googlePlugin = { isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), }); registerGoogleGeminiCliProvider(api); + api.registerImageGenerationProvider(buildGoogleImageGenerationProvider()); api.registerMediaUnderstandingProvider(googleMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index df0750a4284..4f715cab88c 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,9 +1,5 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { DEFAULT_ACCOUNT_ID, type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; -import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; @@ -12,27 +8,4 @@ export const imessageSetupPlugin: ChannelPlugin = { setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), - 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, - }), - }, }; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index a927b8a3d74..973456af7bb 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -4,6 +4,7 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, @@ -14,7 +15,7 @@ import { resolveIMessageGroupToolPolicy, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; -import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { getIMessageRuntime } from "./runtime.js"; @@ -35,14 +36,7 @@ function buildIMessageBaseSessionKey(params: { accountId?: string | null; peer: RoutePeer; }) { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "imessage", - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey({ ...params, channel: "imessage" }); } function resolveIMessageOutboundSessionRoute(params: { diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index bc99f521510..17f1b7487d3 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,8 +1,5 @@ import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, + createPatchedAccountSetupAdapter, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -145,61 +142,29 @@ export const imessageCompletionNote = { ], }; -export const imessageSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, +export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, + buildPatch: (input) => buildIMessageSetupPatch(input), +}); + +export const imessageSetupStatusBase = { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "imsg found", + unconfiguredHint: "imsg missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) => + 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, + ); }), - 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( @@ -208,23 +173,7 @@ export function createIMessageSetupWizardProxy( 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, - ); - }), + ...imessageSetupStatusBase, resolveStatusLines: async (params) => (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], resolveSelectionHint: async (params) => diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 6581fabbacd..27e8b256ada 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -6,6 +6,7 @@ import { imessageCompletionNote, imessageDmPolicy, imessageSetupAdapter, + imessageSetupStatusBase, parseIMessageAllowFromEntries, } from "./setup-core.js"; @@ -14,23 +15,7 @@ const channel = "imessage" as const; export const imessageSetupWizard: ChannelSetupWizard = { 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, - ); - }), + ...imessageSetupStatusBase, resolveStatusLines: async ({ cfg, configured }) => { const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; const cliDetected = await detectBinary(cliPath); diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index ad5c1852b59..20318b2a022 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyModelStudioConfig, applyModelStudioConfigCn, @@ -78,22 +79,13 @@ const modelStudioPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildModelStudioProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildModelStudioProvider, + allowExplicitBaseUrl: true, + }), }, }); }, diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 80bd7af6763..c47c4a92d41 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, @@ -74,22 +75,13 @@ const moonshotPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildMoonshotProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildMoonshotProvider, + allowExplicitBaseUrl: true, + }), }, wrapStreamFn: (ctx) => { const thinkingType = resolveMoonshotThinkingType({ diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index f3f8ea9bef2..6fa8add4405 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,9 +1,5 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { DEFAULT_ACCOUNT_ID, normalizeE164, type ChannelPlugin } from "openclaw/plugin-sdk/signal"; -import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; @@ -12,28 +8,4 @@ export const signalSetupPlugin: ChannelPlugin = { setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), - 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, - }), - }, }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b86f0156a08..17b97c96f25 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -5,8 +5,9 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -123,14 +124,7 @@ function buildSignalBaseSessionKey(params: { accountId?: string | null; peer: RoutePeer; }) { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "signal", - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey({ ...params, channel: "signal" }); } function resolveSignalOutboundSessionRoute(params: { diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 3952a55f861..a1433a34f13 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,8 +1,5 @@ import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, + createPatchedAccountSetupAdapter, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -187,15 +184,8 @@ export const signalCompletionNote = { ], }; -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ input }) => { if ( !input.signalNumber && @@ -208,53 +198,8 @@ export const signalSetupAdapter: ChannelSetupAdapter = { } 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), - }, - }, - }, - }, - }; - }, -}; + buildPatch: (input) => buildSignalSetupPatch(input), +}); export function createSignalSetupWizardProxy( loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index a3b537b1f8e..99d0fe3cbdf 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -9,10 +9,10 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { - buildAgentSessionKey, - resolveThreadSessionKeys, - type RoutePeer, -} from "openclaw/plugin-sdk/routing"; + buildOutboundBaseSessionKey, + normalizeOutboundThreadId, +} from "openclaw/plugin-sdk/core"; +import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, @@ -28,6 +28,7 @@ import { type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; +import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -38,8 +39,6 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import { handleSlackMessageAction } from "./message-action-dispatch.js"; -import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; @@ -137,34 +136,13 @@ function parseSlackExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildSlackBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; accountId?: string | null; peer: RoutePeer; }) { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "slack", - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey({ ...params, channel: "slack" }); } async function resolveSlackChannelType(params: { @@ -509,28 +487,10 @@ export const slackPlugin: ChannelPlugin = { return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, - actions: { - listActions: ({ cfg }) => listSlackMessageActions(cfg), - getCapabilities: ({ cfg }) => { - const capabilities = new Set<"interactive" | "blocks">(); - if (listSlackMessageActions(cfg).includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg })) { - capabilities.add("interactive"); - } - return Array.from(capabilities); - }, - extractToolSend: ({ args }) => extractSlackToolSend(args), - handleAction: async (ctx) => - await handleSlackMessageAction({ - providerId: SLACK_CHANNEL, - ctx, - includeReadThreadId: true, - invoke: async (action, cfg, toolContext) => - await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), - }), - }, + actions: createSlackActions(SLACK_CHANNEL, { + invoke: async (action, cfg, toolContext) => + await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), + }), setup: slackSetupAdapter, outbound: { deliveryMode: "direct", diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts index 63fa59cd347..aaae82a0602 100644 --- a/extensions/slack/src/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -1,7 +1,47 @@ -export { - buildCommandTextFromArgs, - findCommandByNativeName, - listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveCommandArgMenu, +import { + buildCommandTextFromArgs as buildCommandTextFromArgsImpl, + findCommandByNativeName as findCommandByNativeNameImpl, + listNativeCommandSpecsForConfig as listNativeCommandSpecsForConfigImpl, + parseCommandArgs as parseCommandArgsImpl, + resolveCommandArgMenu as resolveCommandArgMenuImpl, } from "openclaw/plugin-sdk/reply-runtime"; + +type BuildCommandTextFromArgs = + typeof import("openclaw/plugin-sdk/reply-runtime").buildCommandTextFromArgs; +type FindCommandByNativeName = + typeof import("openclaw/plugin-sdk/reply-runtime").findCommandByNativeName; +type ListNativeCommandSpecsForConfig = + typeof import("openclaw/plugin-sdk/reply-runtime").listNativeCommandSpecsForConfig; +type ParseCommandArgs = typeof import("openclaw/plugin-sdk/reply-runtime").parseCommandArgs; +type ResolveCommandArgMenu = + typeof import("openclaw/plugin-sdk/reply-runtime").resolveCommandArgMenu; + +export function buildCommandTextFromArgs( + ...args: Parameters +): ReturnType { + return buildCommandTextFromArgsImpl(...args); +} + +export function findCommandByNativeName( + ...args: Parameters +): ReturnType { + return findCommandByNativeNameImpl(...args); +} + +export function listNativeCommandSpecsForConfig( + ...args: Parameters +): ReturnType { + return listNativeCommandSpecsForConfigImpl(...args); +} + +export function parseCommandArgs( + ...args: Parameters +): ReturnType { + return parseCommandArgsImpl(...args); +} + +export function resolveCommandArgMenu( + ...args: Parameters +): ReturnType { + return resolveCommandArgMenuImpl(...args); +} diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts index 0095471359c..3c94004c7b1 100644 --- a/extensions/slack/src/monitor/slash-dispatch.runtime.ts +++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts @@ -1,9 +1,83 @@ -export { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -export { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -export { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime"; -export { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime"; -export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; -export { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime"; -export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -export { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -export { deliverSlackSlashReplies } from "./replies.js"; +import { + createReplyPrefixOptions as createReplyPrefixOptionsImpl, + recordInboundSessionMetaSafe as recordInboundSessionMetaSafeImpl, + resolveConversationLabel as resolveConversationLabelImpl, +} from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime"; +import { + dispatchReplyWithDispatcher as dispatchReplyWithDispatcherImpl, + finalizeInboundContext as finalizeInboundContextImpl, + resolveChunkMode as resolveChunkModeImpl, +} from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute as resolveAgentRouteImpl } from "openclaw/plugin-sdk/routing"; +import { deliverSlackSlashReplies as deliverSlackSlashRepliesImpl } from "./replies.js"; + +type ResolveChunkMode = typeof import("openclaw/plugin-sdk/reply-runtime").resolveChunkMode; +type FinalizeInboundContext = + typeof import("openclaw/plugin-sdk/reply-runtime").finalizeInboundContext; +type DispatchReplyWithDispatcher = + typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher; +type ResolveConversationLabel = + typeof import("openclaw/plugin-sdk/channel-runtime").resolveConversationLabel; +type CreateReplyPrefixOptions = + typeof import("openclaw/plugin-sdk/channel-runtime").createReplyPrefixOptions; +type RecordInboundSessionMetaSafe = + typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; +type ResolveMarkdownTableMode = + typeof import("openclaw/plugin-sdk/config-runtime").resolveMarkdownTableMode; +type ResolveAgentRoute = typeof import("openclaw/plugin-sdk/routing").resolveAgentRoute; +type DeliverSlackSlashReplies = typeof import("./replies.js").deliverSlackSlashReplies; + +export function resolveChunkMode( + ...args: Parameters +): ReturnType { + return resolveChunkModeImpl(...args); +} + +export function finalizeInboundContext( + ...args: Parameters +): ReturnType { + return finalizeInboundContextImpl(...args); +} + +export function dispatchReplyWithDispatcher( + ...args: Parameters +): ReturnType { + return dispatchReplyWithDispatcherImpl(...args); +} + +export function resolveConversationLabel( + ...args: Parameters +): ReturnType { + return resolveConversationLabelImpl(...args); +} + +export function createReplyPrefixOptions( + ...args: Parameters +): ReturnType { + return createReplyPrefixOptionsImpl(...args); +} + +export function recordInboundSessionMetaSafe( + ...args: Parameters +): ReturnType { + return recordInboundSessionMetaSafeImpl(...args); +} + +export function resolveMarkdownTableMode( + ...args: Parameters +): ReturnType { + return resolveMarkdownTableModeImpl(...args); +} + +export function resolveAgentRoute( + ...args: Parameters +): ReturnType { + return resolveAgentRouteImpl(...args); +} + +export function deliverSlackSlashReplies( + ...args: Parameters +): ReturnType { + return deliverSlackSlashRepliesImpl(...args); +} diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts index 738580cdc0f..ec25e104fec 100644 --- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -1 +1,10 @@ -export { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/reply-runtime"; + +type ListSkillCommandsForAgents = + typeof import("openclaw/plugin-sdk/reply-runtime").listSkillCommandsForAgents; + +export function listSkillCommandsForAgents( + ...args: Parameters +): ReturnType { + return listSkillCommandsForAgentsImpl(...args); +} diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 6cd9232b388..af71e5edc52 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,11 +1,8 @@ import { - applyAccountNameToChannelSection, createAllowlistSetupWizardProxy, - createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, + createEnvPatchedAccountSetupAdapter, hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, type OpenClawConfig, noteChannelLookupFailure, noteChannelLookupSummary, @@ -40,77 +37,71 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -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) { +function createSlackTokenCredential(params: { + inputKey: "botToken" | "appToken"; + providerHint: "slack-bot" | "slack-app"; + credentialLabel: string; + preferredEnvVar: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN"; + keepPrompt: string; + inputPrompt: string; +}) { + return { + inputKey: params.inputKey, + providerHint: params.providerHint, + credentialLabel: params.credentialLabel, + preferredEnvVar: params.preferredEnvVar, + envPrompt: `${params.preferredEnvVar} detected. Use env var?`, + keepPrompt: params.keepPrompt, + inputPrompt: params.inputPrompt, + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + const configuredValue = + params.inputKey === "botToken" ? resolved.config.botToken : resolved.config.appToken; + const resolvedValue = params.inputKey === "botToken" ? resolved.botToken : resolved.appToken; return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, + accountConfigured: Boolean(resolvedValue) || hasConfiguredSecretInput(configuredValue), + hasConfiguredValue: hasConfiguredSecretInput(configuredValue), + resolvedValue: resolvedValue?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env[params.preferredEnvVar]?.trim() + : undefined, }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, + }, + 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, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, + [params.inputKey]: value, }, - }, - }; - }, -}; + }), + }; +} + +export const slackSetupAdapter: ChannelSetupAdapter = createEnvPatchedAccountSetupAdapter({ + channelKey: channel, + defaultAccountOnlyEnvError: "Slack env tokens can only be used for the default account.", + missingCredentialError: "Slack requires --bot-token and --app-token (or --use-env).", + hasCredentials: (input) => Boolean(input.botToken && input.appToken), + buildPatch: (input) => ({ + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), +}); export function createSlackSetupWizardBase(handlers: { promptAllowFrom: NonNullable; @@ -169,88 +160,22 @@ export function createSlackSetupWizardBase(handlers: { apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ - { + createSlackTokenCredential({ 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, - }, - }), - }, - { + }), + createSlackTokenCredential({ 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: { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 4e3670ac843..063129267cf 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,50 +1,22 @@ import { - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, - normalizeAccountId, type OpenClawConfig, parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { - ChannelSetupDmPolicy, ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; -import { slackSetupAdapter } from "./setup-core.js"; -import { - buildSlackSetupLines, - isSlackSetupAccountConfigured, - setSlackChannelAllowlist, - SLACK_CHANNEL as channel, -} from "./shared.js"; - -function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { enabled: true }, - }); -} +import { createSlackSetupWizardBase } from "./setup-core.js"; +import { SLACK_CHANNEL as channel } from "./shared.js"; async function resolveSlackAllowFromEntries(params: { token?: string; @@ -117,211 +89,68 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelSetupDmPolicy = { - label: "Slack", - channel, - policyKey: "channels.slack.dmPolicy", - allowFromKey: "channels.slack.allowFrom", - getCurrent: (cfg) => - cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptSlackAllowFrom, -}; - -export const slackSetupWizard: ChannelSetupWizard = { - 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 }) => - !isSlackSetupAccountConfigured(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()) && - !isSlackSetupAccountConfigured(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 === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - 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 }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - 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 === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - 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 }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - 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) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixPattern: /^(slack:|user:)/i, - idPattern: /^[A-Z][A-Z0-9]+$/i, - normalizeId: (id) => id.toUpperCase(), - }), - resolveEntries: async ({ credentialValues, entries }) => - await resolveSlackAllowFromEntries({ - token: credentialValues.botToken, - entries, - }), - apply: ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - groupAccess: { - label: "Slack channels", - placeholder: "#general, #private, C123", - currentPolicy: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) - .filter(([, value]) => value?.allow !== false && value?.enabled !== false) - .map(([key]) => key), - updatePrompt: ({ cfg, accountId }) => - Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), - setPolicy: ({ cfg, accountId, policy }) => - setAccountGroupPolicyForChannel({ - cfg, - channel, - accountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - let keys = entries; - const accountWithTokens = resolveSlackAccount({ - cfg, - accountId, +async function resolveSlackGroupAllowlist(params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; +}) { + let keys = params.entries; + const accountWithTokens = resolveSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const activeBotToken = accountWithTokens.botToken || params.credentialValues.botToken || ""; + if (activeBotToken && params.entries.length > 0) { + try { + const resolved = await resolveSlackChannelAllowlist({ + token: activeBotToken, + entries: params.entries, }); - const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; - if (activeBotToken && entries.length > 0) { - try { - const resolved = await resolveSlackChannelAllowlist({ - token: activeBotToken, - entries, - }); - const resolvedKeys = 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 = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [{ title: "Resolved", values: resolvedKeys }], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - } - } - return keys; + const resolvedKeys = 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 = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + await noteChannelLookupSummary({ + prompter: params.prompter, + label: "Slack channels", + resolvedSections: [{ title: "Resolved", values: resolvedKeys }], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter: params.prompter, + label: "Slack channels", + error, + }); + } + } + return keys; +} + +export const slackSetupWizard: ChannelSetupWizard = createSlackSetupWizardBase(async () => ({ + slackSetupWizard: { + dmPolicy: { + promptAllowFrom: promptSlackAllowFrom, }, - applyAllowlist: ({ cfg, accountId, resolved }) => - setSlackChannelAllowlist(cfg, accountId, resolved as string[]), - }, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; + allowFrom: { + resolveEntries: async ({ credentialValues, entries }) => + await resolveSlackAllowFromEntries({ + token: credentialValues.botToken, + entries, + }), + }, + groupAccess: { + resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => + await resolveSlackGroupAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }), + }, + } as ChannelSetupWizard, +})); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index ddc21911800..88056309ede 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -9,10 +9,10 @@ import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; import { - buildAgentSessionKey, - resolveThreadSessionKeys, - type RoutePeer, -} from "openclaw/plugin-sdk/routing"; + buildOutboundBaseSessionKey, + normalizeOutboundThreadId, +} from "openclaw/plugin-sdk/core"; +import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { buildTokenChannelStatusSummary, @@ -180,34 +180,13 @@ function parseTelegramExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildTelegramBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; accountId?: string | null; peer: RoutePeer; }) { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "telegram", - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey({ ...params, channel: "telegram" }); } function resolveTelegramOutboundSessionRoute(params: { diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index f2b5fc04d77..542fffc0500 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,8 +1,6 @@ import { - applyAccountNameToChannelSection, + createEnvPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, patchChannelConfigForAccount, promptResolvedAllowFrom, splitSetupEntries, @@ -109,78 +107,11 @@ export async function promptTelegramAllowFromForAccount(params: { }); } -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 telegramSetupAdapter: ChannelSetupAdapter = createEnvPatchedAccountSetupAdapter({ + channelKey: channel, + defaultAccountOnlyEnvError: "TELEGRAM_BOT_TOKEN can only be used for the default account.", + missingCredentialError: "Telegram requires token or --token-file (or --use-env).", + hasCredentials: (input) => Boolean(input.token || input.tokenFile), + buildPatch: (input) => + input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}, +}); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 109bf5144a1..33929645968 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { emptyPluginConfigSchema, type OpenClawPluginApi, @@ -10,7 +7,6 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/infra-runtime"; import { applyAuthProfileConfig, buildApiKeyCredential, @@ -23,7 +19,7 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { createZaiToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; -import { fetchZaiUsage } from "openclaw/plugin-sdk/provider-usage"; +import { fetchZaiUsage, resolveLegacyPiAgentAccessToken } from "openclaw/plugin-sdk/provider-usage"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -68,27 +64,6 @@ function resolveGlm5ForwardCompatModel( } as ProviderRuntimeModel); } -function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined { - try { - const authPath = path.join( - resolveRequiredHomeDir(env, os.homedir), - ".pi", - "agent", - "auth.json", - ); - if (!fs.existsSync(authPath)) { - return undefined; - } - const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record< - string, - { access?: string } - >; - return parsed["z-ai"]?.access || parsed.zai?.access; - } catch { - return undefined; - } -} - function resolveZaiDefaultModel(modelIdOverride?: string): string { return modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; } @@ -328,7 +303,7 @@ const zaiPlugin = { if (apiKey) { return { token: apiKey }; } - const legacyToken = resolveLegacyZaiUsageToken(ctx.env); + const legacyToken = resolveLegacyPiAgentAccessToken(ctx.env, ["z-ai", "zai"]); return legacyToken ? { token: legacyToken } : null; }, fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 1c346b54a78..09978543bdd 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -4,15 +4,33 @@ import { spawnSync } from "node:child_process"; const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; const extraArgs = process.argv.slice(2); +const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/; const result = spawnSync( "pnpm", ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs], { - stdio: "inherit", + encoding: "utf8", + stdio: "pipe", shell: process.platform === "win32", }, ); +const stdout = result.stdout ?? ""; +const stderr = result.stderr ?? ""; +if (stdout) { + process.stdout.write(stdout); +} +if (stderr) { + process.stderr.write(stderr); +} + +if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stderr}`)) { + console.error( + "Build emitted [INEFFECTIVE_DYNAMIC_IMPORT]. Replace transparent runtime re-export facades with real runtime boundaries.", + ); + process.exit(1); +} + if (typeof result.status === "number") { process.exit(result.status); } diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 149a4785dd5..4a0223af7a4 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -40,6 +40,7 @@ export type ProcessGatewayAllowlistParams = { command: string; workdir: string; env: Record; + requestedEnv?: Record; pty: boolean; timeoutSec?: number; defaultTimeoutSec: number; @@ -152,6 +153,7 @@ export async function processGatewayAllowlist( await registerExecApprovalRequestForHostOrThrow({ approvalId, command: params.command, + env: params.requestedEnv, workdir: params.workdir, host: "gateway", security: hostSecurity, diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 8a0bd30907a..5fe0f7deac4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -429,6 +429,7 @@ export function createExecTool( command: params.command, workdir, env, + requestedEnv: params.env, pty: params.pty === true && !sandbox, timeoutSec: params.timeout, defaultTimeoutSec, diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index bee7a2d9036..a4d65cc964c 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -6,8 +6,10 @@ import { buildBootstrapTruncationReportMeta, buildBootstrapTruncationSignature, formatBootstrapTruncationWarningLines, + prependBootstrapPromptWarning, resolveBootstrapWarningSignaturesSeen, } from "./bootstrap-budget.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; describe("buildBootstrapInjectionStats", () => { @@ -104,6 +106,34 @@ describe("analyzeBootstrapBudget", () => { }); describe("bootstrap prompt warnings", () => { + it("prepends warning details to the turn prompt instead of mutating the system prompt", () => { + const prompt = prependBootstrapPromptWarning("Please continue.", [ + "AGENTS.md: 200 raw -> 0 injected", + ]); + expect(prompt).toContain("[Bootstrap truncation warning]"); + expect(prompt).toContain("Treat Project Context as partial"); + expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected"); + expect(prompt).toContain("Please continue."); + }); + + it("preserves raw prompt whitespace when prepending warning details", () => { + const prompt = prependBootstrapPromptWarning(" indented\nkeep tail ", [ + "AGENTS.md: 200 raw -> 0 injected", + ]); + + expect(prompt.endsWith(" indented\nkeep tail ")).toBe(true); + }); + + it("preserves exact heartbeat prompts without warning prefixes", () => { + const heartbeatPrompt = "Read HEARTBEAT.md. Reply HEARTBEAT_OK."; + + expect( + prependBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], { + preserveExactPrompt: heartbeatPrompt, + }), + ).toBe(heartbeatPrompt); + }); + it("resolves seen signatures from report history or legacy single signature", () => { expect( resolveBootstrapWarningSignaturesSeen({ @@ -394,4 +424,35 @@ describe("bootstrap prompt warnings", () => { expect(meta.promptWarningSignature).toBeTruthy(); expect(meta.warningSignaturesSeen?.length).toBeGreaterThan(0); }); + + it("improves cache-relevant system prompt stability versus legacy warning injection", () => { + const contextFiles = [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }]; + const warningLines = ["AGENTS.md: 200 raw -> 0 injected"]; + const stableSystemPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + contextFiles, + }); + const optimizedTurns = [stableSystemPrompt, stableSystemPrompt, stableSystemPrompt]; + const injectLegacyWarning = (prompt: string, lines: string[]) => { + const warningBlock = [ + "⚠ Bootstrap truncation warning:", + ...lines.map((line) => `- ${line}`), + "", + ].join("\n"); + return prompt.replace("## AGENTS.md", `${warningBlock}## AGENTS.md`); + }; + const legacyTurns = [ + injectLegacyWarning(optimizedTurns[0] ?? "", warningLines), + optimizedTurns[1] ?? "", + injectLegacyWarning(optimizedTurns[2] ?? "", warningLines), + ]; + const cacheHitRate = (turns: string[]) => { + const hits = turns.slice(1).filter((turn, index) => turn === turns[index]).length; + return hits / Math.max(1, turns.length - 1); + }; + + expect(cacheHitRate(legacyTurns)).toBe(0); + expect(cacheHitRate(optimizedTurns)).toBe(1); + expect(optimizedTurns[0]).not.toContain("⚠ Bootstrap truncation warning:"); + }); }); diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts index ddfd4fb5d06..4d5c3ff6f58 100644 --- a/src/agents/bootstrap-budget.ts +++ b/src/agents/bootstrap-budget.ts @@ -330,6 +330,29 @@ export function buildBootstrapPromptWarning(params: { }; } +export function prependBootstrapPromptWarning( + prompt: string, + warningLines?: string[], + options?: { + preserveExactPrompt?: string; + }, +): string { + const normalizedLines = (warningLines ?? []).map((line) => line.trim()).filter(Boolean); + if (normalizedLines.length === 0) { + return prompt; + } + if (options?.preserveExactPrompt && prompt === options.preserveExactPrompt) { + return prompt; + } + const warningBlock = [ + "[Bootstrap truncation warning]", + "Some workspace bootstrap files were truncated before injection.", + "Treat Project Context as partial and read the relevant files directly if details seem missing.", + ...normalizedLines.map((line) => `- ${line}`), + ].join("\n"); + return prompt ? `${warningBlock}\n\n${prompt}` : warningBlock; +} + export function buildBootstrapTruncationReportMeta(params: { analysis: BootstrapBudgetAnalysis; warningMode: BootstrapPromptWarningMode; diff --git a/src/agents/cli-runner.test.ts b/src/agents/cli-runner.test.ts index ec1b0b09ac8..e77ac021fd7 100644 --- a/src/agents/cli-runner.test.ts +++ b/src/agents/cli-runner.test.ts @@ -5,10 +5,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { runCliAgent } from "./cli-runner.js"; import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; +import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { WorkspaceBootstrapFile } from "./workspace.js"; const supervisorSpawnMock = vi.fn(); const enqueueSystemEventMock = vi.fn(); const requestHeartbeatNowMock = vi.fn(); +const hoisted = vi.hoisted(() => { + type BootstrapContext = { + bootstrapFiles: WorkspaceBootstrapFile[]; + contextFiles: EmbeddedContextFile[]; + }; + + return { + resolveBootstrapContextForRunMock: vi.fn<() => Promise>(async () => ({ + bootstrapFiles: [], + contextFiles: [], + })), + }; +}); vi.mock("../process/supervisor/index.js", () => ({ getProcessSupervisor: () => ({ @@ -28,6 +43,11 @@ vi.mock("../infra/heartbeat-wake.js", () => ({ requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), })); +vi.mock("./bootstrap-files.js", () => ({ + makeBootstrapWarn: () => () => {}, + resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, +})); + type MockRunExit = { reason: | "manual-cancel" @@ -61,6 +81,10 @@ describe("runCliAgent with process supervisor", () => { supervisorSpawnMock.mockClear(); enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); + hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({ + bootstrapFiles: [], + contextFiles: [], + }); }); it("runs CLI through supervisor and returns payload", async () => { @@ -107,6 +131,62 @@ describe("runCliAgent with process supervisor", () => { expect(input.scopeKey).toContain("thread-123"); }); + it("prepends bootstrap warnings to the CLI prompt body", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "ok", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); + hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce({ + bootstrapFiles: [ + { + name: "AGENTS.md", + path: "/tmp/AGENTS.md", + content: "A".repeat(200), + missing: false, + }, + ], + contextFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }], + }); + + await runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: { + agents: { + defaults: { + bootstrapMaxChars: 50, + bootstrapTotalMaxChars: 50, + }, + }, + } satisfies OpenClawConfig, + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-warning", + cliSessionId: "thread-123", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + argv?: string[]; + input?: string; + }; + const promptCarrier = [input.input ?? "", ...(input.argv ?? [])].join("\n"); + + expect(promptCarrier).toContain("[Bootstrap truncation warning]"); + expect(promptCarrier).toContain("- AGENTS.md: 200 raw -> 20 injected"); + expect(promptCarrier).toContain("hi"); + }); + it("fails with timeout when no-output watchdog trips", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 93cebc6dd14..9056668e087 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -15,6 +15,7 @@ import { buildBootstrapInjectionStats, buildBootstrapPromptWarning, buildBootstrapTruncationReportMeta, + prependBootstrapPromptWarning, } from "./bootstrap-budget.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; @@ -162,7 +163,6 @@ export async function runCliAgent(params: { docsPath: docsPath ?? undefined, tools: [], contextFiles, - bootstrapTruncationWarningLines: bootstrapPromptWarning.lines, modelDisplay, agentId: sessionAgentId, }); @@ -218,7 +218,9 @@ export async function runCliAgent(params: { let imagePaths: string[] | undefined; let cleanupImages: (() => Promise) | undefined; - let prompt = params.prompt; + let prompt = prependBootstrapPromptWarning(params.prompt, bootstrapPromptWarning.lines, { + preserveExactPrompt: heartbeatPrompt, + }); if (params.images && params.images.length > 0) { const imagePayload = await writeCliImages(params.images); imagePaths = imagePayload.paths; diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 7f0598cfaab..96ec35540be 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -48,7 +48,6 @@ export function buildSystemPrompt(params: { docsPath?: string; tools: AgentTool[]; contextFiles?: EmbeddedContextFile[]; - bootstrapTruncationWarningLines?: string[]; modelDisplay: string; agentId?: string; }) { @@ -92,7 +91,6 @@ export function buildSystemPrompt(params: { userTime, userTimeFormat, contextFiles: params.contextFiles, - bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines, ttsHint, memoryCitationsMode: params.config?.memory?.citations, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index e67bb20d88d..fa2bb58fbbc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -18,16 +18,27 @@ import type { IngestBatchResult, IngestResult, } from "../../../context-engine/types.js"; +import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; +import type { WorkspaceBootstrapFile } from "../../workspace.js"; const hoisted = vi.hoisted(() => { + type BootstrapContext = { + bootstrapFiles: WorkspaceBootstrapFile[]; + contextFiles: EmbeddedContextFile[]; + }; const spawnSubagentDirectMock = vi.fn(); const createAgentSessionMock = vi.fn(); const sessionManagerOpenMock = vi.fn(); const resolveSandboxContextMock = vi.fn(); const subscribeEmbeddedPiSessionMock = vi.fn(); const acquireSessionWriteLockMock = vi.fn(); + const resolveBootstrapContextForRunMock = vi.fn<() => Promise>(async () => ({ + bootstrapFiles: [], + contextFiles: [], + })); + const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); const sessionManager = { getLeafEntry: vi.fn(() => null), branch: vi.fn(), @@ -42,6 +53,8 @@ const hoisted = vi.hoisted(() => { resolveSandboxContextMock, subscribeEmbeddedPiSessionMock, acquireSessionWriteLockMock, + resolveBootstrapContextForRunMock, + getGlobalHookRunnerMock, sessionManager, }; }); @@ -80,7 +93,7 @@ vi.mock("../../pi-embedded-subscribe.js", () => ({ })); vi.mock("../../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => undefined, + getGlobalHookRunner: hoisted.getGlobalHookRunnerMock, })); vi.mock("../../../infra/machine-name.js", () => ({ @@ -94,7 +107,7 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ vi.mock("../../bootstrap-files.js", () => ({ makeBootstrapWarn: () => () => {}, - resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }), + resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, })); vi.mock("../../skills.js", () => ({ @@ -269,6 +282,11 @@ function resetEmbeddedAttemptHarness( hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ release: async () => {}, }); + hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({ + bootstrapFiles: [], + contextFiles: [], + }); + hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined); hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); hoisted.sessionManager.branch.mockReset(); hoisted.sessionManager.resetLeaf.mockReset(); @@ -291,7 +309,11 @@ async function cleanupTempPaths(tempPaths: string[]) { } function createDefaultEmbeddedSession(params?: { - prompt?: (session: MutableSession) => Promise; + prompt?: ( + session: MutableSession, + prompt: string, + options?: { images?: unknown[] }, + ) => Promise; }): MutableSession { const session: MutableSession = { sessionId: "embedded-session", @@ -303,9 +325,9 @@ function createDefaultEmbeddedSession(params?: { session.messages = [...messages]; }, }, - prompt: async () => { + prompt: async (prompt, options) => { if (params?.prompt) { - await params.prompt(session); + await params.prompt(session, prompt, options); return; } session.messages = [ @@ -450,6 +472,90 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { }); }); +describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => { + const tempPaths: string[] = []; + + beforeEach(() => { + resetEmbeddedAttemptHarness({ + subscribeImpl: createSubscriptionMock, + }); + }); + + afterEach(async () => { + await cleanupTempPaths(tempPaths); + }); + + it("keeps bootstrap warnings in the sent prompt after hook prepend context", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-workspace-")); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-agent-dir-")); + const sessionFile = path.join(workspaceDir, "session.jsonl"); + tempPaths.push(workspaceDir, agentDir); + await fs.writeFile(sessionFile, "", "utf8"); + + hoisted.resolveBootstrapContextForRunMock.mockResolvedValue({ + bootstrapFiles: [ + { + name: "AGENTS.md", + path: path.join(workspaceDir, "AGENTS.md"), + content: "A".repeat(200), + missing: false, + }, + ], + contextFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }], + }); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: (hookName: string) => hookName === "before_prompt_build", + runBeforePromptBuild: async () => ({ prependContext: "hook context" }), + }); + + let seenPrompt = ""; + hoisted.createAgentSessionMock.mockImplementation(async () => ({ + session: createDefaultEmbeddedSession({ + prompt: async (session, prompt) => { + seenPrompt = prompt; + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }), + })); + + const result = await runEmbeddedAttempt({ + sessionId: "embedded-session", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir, + agentDir, + config: { + agents: { + defaults: { + bootstrapMaxChars: 50, + bootstrapTotalMaxChars: 50, + }, + }, + }, + prompt: "hello", + timeoutMs: 10_000, + runId: "run-warning", + provider: "openai", + modelId: "gpt-test", + model: testModel, + authStorage: {} as AuthStorage, + modelRegistry: {} as ModelRegistry, + thinkLevel: "off", + senderIsOwner: true, + disableMessageTool: true, + }); + + expect(result.promptError).toBeNull(); + expect(seenPrompt).toContain("hook context"); + expect(seenPrompt).toContain("[Bootstrap truncation warning]"); + expect(seenPrompt).toContain("- AGENTS.md: 200 raw -> 20 injected"); + expect(seenPrompt).toContain("hello"); + }); +}); + describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => { const tempPaths: string[] = []; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index dc9df12865d..73b7d0fbff6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -41,6 +41,7 @@ import { buildBootstrapPromptWarning, buildBootstrapTruncationReportMeta, buildBootstrapInjectionStats, + prependBootstrapPromptWarning, } from "../../bootstrap-budget.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -1665,6 +1666,9 @@ export async function runEmbeddedAttempt( }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; const ownerDisplay = resolveOwnerDisplaySetting(params.config); + const heartbeatPrompt = isDefaultAgent + ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) + : undefined; const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, @@ -1675,9 +1679,7 @@ export async function runEmbeddedAttempt( ownerDisplay: ownerDisplay.ownerDisplay, ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, reasoningTagHint, - heartbeatPrompt: isDefaultAgent - ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) - : undefined, + heartbeatPrompt, skillsPrompt, docsPath: docsPath ?? undefined, ttsHint, @@ -1694,7 +1696,6 @@ export async function runEmbeddedAttempt( userTime, userTimeFormat, contextFiles, - bootstrapTruncationWarningLines: bootstrapPromptWarning.lines, memoryCitationsMode: params.config?.memory?.citations, }); const systemPromptReport = buildSystemPromptReport({ @@ -2378,7 +2379,13 @@ export async function runEmbeddedAttempt( // Run before_prompt_build hooks to allow plugins to inject prompt context. // Legacy compatibility: before_agent_start is also checked for context fields. - let effectivePrompt = params.prompt; + let effectivePrompt = prependBootstrapPromptWarning( + params.prompt, + bootstrapPromptWarning.lines, + { + preserveExactPrompt: heartbeatPrompt, + }, + ); const hookCtx = { agentId: hookAgentId, sessionKey: params.sessionKey, @@ -2397,7 +2404,7 @@ export async function runEmbeddedAttempt( }); { if (hookResult?.prependContext) { - effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`; + effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`; log.debug( `hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`, ); diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index ac2662f127f..ef246d1af23 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -51,7 +51,6 @@ export function buildEmbeddedSystemPrompt(params: { userTime?: string; userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; - bootstrapTruncationWarningLines?: string[]; memoryCitationsMode?: MemoryCitationsMode; }): string { return buildAgentSystemPrompt({ @@ -81,7 +80,6 @@ export function buildEmbeddedSystemPrompt(params: { userTime: params.userTime, userTimeFormat: params.userTimeFormat, contextFiles: params.contextFiles, - bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines, memoryCitationsMode: params.memoryCitationsMode, }); } diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 3877f6fed21..b20a9524941 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -534,16 +534,13 @@ describe("buildAgentSystemPrompt", () => { ); }); - it("renders bootstrap truncation warning even when no context files are injected", () => { + it("omits project context when no context files are injected", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", - bootstrapTruncationWarningLines: ["AGENTS.md: 200 raw -> 0 injected"], contextFiles: [], }); - expect(prompt).toContain("# Project Context"); - expect(prompt).toContain("⚠ Bootstrap truncation warning:"); - expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected"); + expect(prompt).not.toContain("# Project Context"); }); it("summarizes the message tool when available", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 848222b7880..5f4ee932bd7 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -202,7 +202,6 @@ export function buildAgentSystemPrompt(params: { userTime?: string; userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; - bootstrapTruncationWarningLines?: string[]; skillsPrompt?: string; heartbeatPrompt?: string; docsPath?: string; @@ -614,13 +613,10 @@ export function buildAgentSystemPrompt(params: { } const contextFiles = params.contextFiles ?? []; - const bootstrapTruncationWarningLines = (params.bootstrapTruncationWarningLines ?? []).filter( - (line) => line.trim().length > 0, - ); const validContextFiles = contextFiles.filter( (file) => typeof file.path === "string" && file.path.trim().length > 0, ); - if (validContextFiles.length > 0 || bootstrapTruncationWarningLines.length > 0) { + if (validContextFiles.length > 0) { lines.push("# Project Context", ""); if (validContextFiles.length > 0) { const hasSoulFile = validContextFiles.some((file) => { @@ -636,13 +632,6 @@ export function buildAgentSystemPrompt(params: { } lines.push(""); } - if (bootstrapTruncationWarningLines.length > 0) { - lines.push("⚠ Bootstrap truncation warning:"); - for (const warningLine of bootstrapTruncationWarningLines) { - lines.push(`- ${warningLine}`); - } - lines.push(""); - } for (const file of validContextFiles) { lines.push(`## ${file.path}`, "", file.content, ""); } diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 2040271f540..f81de4fe4ed 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { applySetupAccountConfigPatch, + createEnvPatchedAccountSetupAdapter, createPatchedAccountSetupAdapter, prepareScopedSetupConfig, } from "./setup-helpers.js"; @@ -162,6 +163,39 @@ describe("createPatchedAccountSetupAdapter", () => { }); }); +describe("createEnvPatchedAccountSetupAdapter", () => { + it("rejects env mode for named accounts and requires credentials otherwise", () => { + const adapter = createEnvPatchedAccountSetupAdapter({ + channelKey: "telegram", + defaultAccountOnlyEnvError: "env only on default", + missingCredentialError: "token required", + hasCredentials: (input) => Boolean(input.token || input.tokenFile), + buildPatch: (input) => ({ token: input.token }), + }); + + expect( + adapter.validateInput?.({ + accountId: "work", + input: { useEnv: true }, + }), + ).toBe("env only on default"); + + expect( + adapter.validateInput?.({ + accountId: DEFAULT_ACCOUNT_ID, + input: {}, + }), + ).toBe("token required"); + + expect( + adapter.validateInput?.({ + accountId: DEFAULT_ACCOUNT_ID, + input: { token: "tok" }, + }), + ).toBeNull(); + }); +}); + describe("prepareScopedSetupConfig", () => { it("stores the name and migrates it for named accounts when requested", () => { const next = prepareScopedSetupConfig({ diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index cfbd58a8d4e..e27f13e383a 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -204,6 +204,35 @@ export function createPatchedAccountSetupAdapter(params: { }; } +export function createEnvPatchedAccountSetupAdapter(params: { + channelKey: string; + alwaysUseAccounts?: boolean; + ensureChannelEnabled?: boolean; + ensureAccountEnabled?: boolean; + defaultAccountOnlyEnvError: string; + missingCredentialError: string; + hasCredentials: (input: ChannelSetupInput) => boolean; + validateInput?: ChannelSetupAdapter["validateInput"]; + buildPatch: (input: ChannelSetupInput) => Record; +}): ChannelSetupAdapter { + return createPatchedAccountSetupAdapter({ + channelKey: params.channelKey, + alwaysUseAccounts: params.alwaysUseAccounts, + ensureChannelEnabled: params.ensureChannelEnabled, + ensureAccountEnabled: params.ensureAccountEnabled, + validateInput: (inputParams) => { + if (inputParams.input.useEnv && inputParams.accountId !== DEFAULT_ACCOUNT_ID) { + return params.defaultAccountOnlyEnvError; + } + if (!inputParams.input.useEnv && !params.hasCredentials(inputParams.input)) { + return params.missingCredentialError; + } + return params.validateInput?.(inputParams) ?? null; + }, + buildPatch: params.buildPatch, + }); +} + export function patchScopedAccountConfig(params: { cfg: OpenClawConfig; channelKey: string; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index d559ca99b6a..e65a85d98f6 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -8,7 +8,16 @@ import { } from "../../plugin-sdk/slack.js"; import type { ChannelMessageActionAdapter } from "./types.js"; -export function createSlackActions(providerId: string): ChannelMessageActionAdapter { +type SlackActionInvoke = ( + action: Record, + cfg: unknown, + toolContext: unknown, +) => Promise; + +export function createSlackActions( + providerId: string, + options?: { invoke?: SlackActionInvoke }, +): ChannelMessageActionAdapter { return { listActions: ({ cfg }) => listSlackMessageActions(cfg), getCapabilities: ({ cfg }) => { @@ -29,10 +38,12 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap normalizeChannelId: resolveSlackChannelId, includeReadThreadId: true, invoke: async (action, cfg, toolContext) => - await handleSlackAction(action, cfg, { - ...(toolContext as SlackActionContext | undefined), - mediaLocalRoots: ctx.mediaLocalRoots, - }), + await (options?.invoke + ? options.invoke(action, cfg, toolContext) + : handleSlackAction(action, cfg, { + ...(toolContext as SlackActionContext | undefined), + mediaLocalRoots: ctx.mediaLocalRoots, + })), }); }, }; diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 505084ef992..73b0b1feae6 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -2,53 +2,25 @@ import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { resolveConfigPath, resolveGatewayPort, resolveStateDir } from "../config/paths.js"; +import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; -import { isSecureWebSocketUrl } from "../gateway/net.js"; -import { probeGateway } from "../gateway/probe.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import type { MemoryProviderStatus } from "../memory/types.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; -import { - pickGatewaySelfPresence, - resolveGatewayProbeAuthResolution, -} from "./status.gateway-probe.js"; import type { StatusScanResult } from "./status.scan.js"; +import { + buildTailscaleHttpsUrl, + pickGatewaySelfPresence, + resolveGatewayProbeSnapshot, + resolveMemoryPluginStatus, + resolveSharedMemoryStatusSnapshot, + type MemoryPluginStatus, + type MemoryStatusSnapshot, +} from "./status.scan.shared.js"; import { getStatusSummary } from "./status.summary.js"; import { getUpdateCheckResult } from "./status.update.js"; -type MemoryStatusSnapshot = MemoryProviderStatus & { - agentId: string; -}; - -type MemoryPluginStatus = { - enabled: boolean; - slot: string | null; - reason?: string; -}; - -type GatewayConnectionDetails = { - url: string; - urlSource: string; - bindDetail?: string; - remoteFallbackNote?: string; - message: string; -}; - -type GatewayProbeSnapshot = { - gatewayConnection: GatewayConnectionDetails; - remoteUrlMissing: boolean; - gatewayMode: "local" | "remote"; - gatewayProbeAuth: { - token?: string; - password?: string; - }; - gatewayProbeAuthWarning?: string; - gatewayProbe: Awaited> | null; -}; - let pluginRegistryModulePromise: Promise | undefined; let configIoModulePromise: Promise | undefined; let commandSecretTargetsModulePromise: @@ -100,204 +72,25 @@ function shouldSkipMissingConfigFastPath(): boolean { ); } -function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { - if ( - cfg.agents?.defaults && - Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") - ) { - return true; - } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - return agents.some( - (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), - ); -} - -function normalizeControlUiBasePath(basePath?: string): string { - if (!basePath) { - return ""; - } - let normalized = basePath.trim(); - if (!normalized) { - return ""; - } - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - if (normalized === "/") { - return ""; - } - if (normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - return normalized; -} - -function trimToUndefined(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function buildGatewayConnectionDetails(options: { - config: OpenClawConfig; - url?: string; - configPath?: string; - urlSource?: "cli" | "env"; -}): GatewayConnectionDetails { - const config = options.config; - const configPath = - options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); - const isRemoteMode = config.gateway?.mode === "remote"; - const remote = isRemoteMode ? config.gateway?.remote : undefined; - const tlsEnabled = config.gateway?.tls?.enabled === true; - const localPort = resolveGatewayPort(config); - const bindMode = config.gateway?.bind ?? "loopback"; - const scheme = tlsEnabled ? "wss" : "ws"; - const localUrl = `${scheme}://127.0.0.1:${localPort}`; - const cliUrlOverride = - typeof options.url === "string" && options.url.trim().length > 0 - ? options.url.trim() - : undefined; - const envUrlOverride = cliUrlOverride - ? undefined - : (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ?? - trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL)); - const urlOverride = cliUrlOverride ?? envUrlOverride; - const remoteUrl = - typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; - const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl; - const urlSourceHint = - options.urlSource ?? (cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined); - const url = urlOverride || remoteUrl || localUrl; - const urlSource = urlOverride - ? urlSourceHint === "env" - ? "env OPENCLAW_GATEWAY_URL" - : "cli --url" - : remoteUrl - ? "config gateway.remote.url" - : remoteMisconfigured - ? "missing gateway.remote.url (fallback local)" - : "local loopback"; - const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; - const remoteFallbackNote = remoteMisconfigured - ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." - : undefined; - const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; - if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { - throw new Error( - [ - `SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`, - "Both credentials and chat data would be exposed to network interception.", - `Source: ${urlSource}`, - `Config: ${configPath}`, - ].join("\n"), - ); - } - return { - url, - urlSource, - bindDetail, - remoteFallbackNote, - message: [ - `Gateway target: ${url}`, - `Source: ${urlSource}`, - `Config: ${configPath}`, - bindDetail, - remoteFallbackNote, - ] - .filter(Boolean) - .join("\n"), - }; -} - function resolveDefaultMemoryStorePath(agentId: string): string { return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`); } -function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStatus { - const pluginsEnabled = cfg.plugins?.enabled !== false; - if (!pluginsEnabled) { - return { enabled: false, slot: null, reason: "plugins disabled" }; - } - const raw = typeof cfg.plugins?.slots?.memory === "string" ? cfg.plugins.slots.memory.trim() : ""; - if (raw && raw.toLowerCase() === "none") { - return { enabled: false, slot: null, reason: 'plugins.slots.memory="none"' }; - } - return { enabled: true, slot: raw || "memory-core" }; -} - -async function resolveGatewayProbeSnapshot(params: { - cfg: OpenClawConfig; - opts: { timeoutMs?: number; all?: boolean }; -}): Promise { - const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); - const isRemoteMode = params.cfg.gateway?.mode === "remote"; - const remoteUrlRaw = - typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); - const gatewayMode = isRemoteMode ? "remote" : "local"; - const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg); - let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; - const gatewayProbe = remoteUrlMissing - ? null - : await probeGateway({ - 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 - ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` - : gatewayProbeAuthWarning; - gatewayProbeAuthWarning = undefined; - } - return { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth: gatewayProbeAuthResolution.auth, - gatewayProbeAuthWarning, - gatewayProbe, - }; -} - async function resolveMemoryStatusSnapshot(params: { cfg: OpenClawConfig; agentStatus: Awaited>; memoryPlugin: MemoryPluginStatus; }): Promise { - const { cfg, agentStatus, memoryPlugin } = params; - if (!memoryPlugin.enabled || memoryPlugin.slot !== "memory-core") { - return null; - } - const agentId = agentStatus.defaultId ?? "main"; - const explicitMemoryConfig = hasExplicitMemorySearchConfig(cfg, agentId); - const defaultStorePath = resolveDefaultMemoryStorePath(agentId); - if (!explicitMemoryConfig && !existsSync(defaultStorePath)) { - return null; - } const { resolveMemorySearchConfig } = await loadMemorySearchModule(); - const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); - if (!resolvedMemory) { - return null; - } - const shouldInspectStore = - hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); - if (!shouldInspectStore) { - return null; - } const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); - const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - if (!manager) { - return null; - } - try { - await manager.probeVectorAvailability(); - } catch {} - const status = manager.status(); - await manager.close?.().catch(() => {}); - return { agentId, ...status }; + return await resolveSharedMemoryStatusSnapshot({ + cfg: params.cfg, + agentStatus: params.agentStatus, + memoryPlugin: params.memoryPlugin, + resolveMemoryConfig: resolveMemorySearchConfig, + getMemorySearchManager, + requireDefaultStore: resolveDefaultMemoryStorePath, + }); } async function readStatusSourceConfig(): Promise { @@ -372,10 +165,11 @@ export async function scanStatusJsonFast( gatewayProbePromise, summaryPromise, ]); - const tailscaleHttpsUrl = - tailscaleMode !== "off" && tailscaleDns - ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` - : null; + const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ + tailscaleMode, + tailscaleDns, + controlUiBasePath: cfg.gateway?.controlUi?.basePath, + }); const { gatewayConnection, diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts new file mode 100644 index 00000000000..b855c85320a --- /dev/null +++ b/src/commands/status.scan.shared.ts @@ -0,0 +1,157 @@ +import { existsSync } from "node:fs"; +import type { OpenClawConfig } from "../config/types.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; +import { probeGateway } from "../gateway/probe.js"; +import type { MemoryProviderStatus } from "../memory/types.js"; +import { + pickGatewaySelfPresence, + resolveGatewayProbeAuthResolution, +} from "./status.gateway-probe.js"; + +export type MemoryStatusSnapshot = MemoryProviderStatus & { + agentId: string; +}; + +export type MemoryPluginStatus = { + enabled: boolean; + slot: string | null; + reason?: string; +}; + +export type GatewayProbeSnapshot = { + gatewayConnection: ReturnType; + remoteUrlMissing: boolean; + gatewayMode: "local" | "remote"; + gatewayProbeAuth: { + token?: string; + password?: string; + }; + gatewayProbeAuthWarning?: string; + gatewayProbe: Awaited> | null; +}; + +export function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { + if ( + cfg.agents?.defaults && + Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") + ) { + return true; + } + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + return agents.some( + (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), + ); +} + +export function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStatus { + const pluginsEnabled = cfg.plugins?.enabled !== false; + if (!pluginsEnabled) { + return { enabled: false, slot: null, reason: "plugins disabled" }; + } + const raw = typeof cfg.plugins?.slots?.memory === "string" ? cfg.plugins.slots.memory.trim() : ""; + if (raw && raw.toLowerCase() === "none") { + return { enabled: false, slot: null, reason: 'plugins.slots.memory="none"' }; + } + return { enabled: true, slot: raw || "memory-core" }; +} + +export async function resolveGatewayProbeSnapshot(params: { + cfg: OpenClawConfig; + opts: { timeoutMs?: number; all?: boolean }; +}): Promise { + const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); + const isRemoteMode = params.cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); + const gatewayMode = isRemoteMode ? "remote" : "local"; + const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg); + let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; + const gatewayProbe = remoteUrlMissing + ? null + : await probeGateway({ + 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 + ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` + : gatewayProbeAuthWarning; + gatewayProbeAuthWarning = undefined; + } + return { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth: gatewayProbeAuthResolution.auth, + gatewayProbeAuthWarning, + gatewayProbe, + }; +} + +export function buildTailscaleHttpsUrl(params: { + tailscaleMode: string; + tailscaleDns: string | null; + controlUiBasePath?: string; +}): string | null { + return params.tailscaleMode !== "off" && params.tailscaleDns + ? `https://${params.tailscaleDns}${normalizeControlUiBasePath(params.controlUiBasePath)}` + : null; +} + +export async function resolveSharedMemoryStatusSnapshot(params: { + cfg: OpenClawConfig; + agentStatus: { defaultId?: string | null }; + memoryPlugin: MemoryPluginStatus; + resolveMemoryConfig: (cfg: OpenClawConfig, agentId: string) => { store: { path: string } } | null; + getMemorySearchManager: (params: { + cfg: OpenClawConfig; + agentId: string; + purpose: "status"; + }) => Promise<{ + manager: { + probeVectorAvailability(): Promise; + status(): MemoryProviderStatus; + close?(): Promise; + } | null; + }>; + requireDefaultStore?: (agentId: string) => string | null; +}): Promise { + const { cfg, agentStatus, memoryPlugin } = params; + if (!memoryPlugin.enabled || memoryPlugin.slot !== "memory-core") { + return null; + } + const agentId = agentStatus.defaultId ?? "main"; + const defaultStorePath = params.requireDefaultStore?.(agentId); + if ( + defaultStorePath && + !hasExplicitMemorySearchConfig(cfg, agentId) && + !existsSync(defaultStorePath) + ) { + return null; + } + const resolvedMemory = params.resolveMemoryConfig(cfg, agentId); + if (!resolvedMemory) { + return null; + } + const shouldInspectStore = + hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); + if (!shouldInspectStore) { + return null; + } + const { manager } = await params.getMemorySearchManager({ cfg, agentId, purpose: "status" }); + if (!manager) { + return null; + } + try { + await manager.probeVectorAvailability(); + } catch {} + const status = manager.status(); + await manager.close?.().catch(() => {}); + return { agentId, ...status }; +} + +export { pickGatewaySelfPresence }; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index edb77ae4fcf..168c2f55017 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + hasPotentialConfiguredChannels: vi.fn(), readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), buildChannelsTable: vi.fn(), @@ -15,6 +16,15 @@ const mocks = vi.hoisted(() => ({ ensurePluginRegistryLoaded: vi.fn(), })); +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasPotentialConfiguredChannels.mockReturnValue(false); +}); + +vi.mock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, +})); + vi.mock("../cli/progress.js", () => ({ withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })), })); @@ -333,6 +343,7 @@ describe("scanStatus", () => { }); it("preloads configured channel plugins for status --json when channel config exists", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); mocks.readBestEffortConfig.mockResolvedValue({ session: {}, plugins: { enabled: false }, @@ -395,6 +406,7 @@ describe("scanStatus", () => { }); it("preloads configured channel plugins for status --json when channel auth is env-only", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; process.env.MATRIX_ACCESS_TOKEN = "token"; mocks.readBestEffortConfig.mockResolvedValue({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 6c2bd67f3dd..3eb6fc8ed3d 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,4 +1,3 @@ -import { existsSync } from "node:fs"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; @@ -6,62 +5,30 @@ import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.j import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; 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 { callGateway } from "../gateway/call.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import type { MemoryProviderStatus } from "../memory/types.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; -import { - pickGatewaySelfPresence, - resolveGatewayProbeAuthResolution, -} from "./status.gateway-probe.js"; import type { buildChannelsTable as buildChannelsTableFn, collectChannelStatusIssues as collectChannelStatusIssuesFn, } from "./status.scan.runtime.js"; +import { + buildTailscaleHttpsUrl, + pickGatewaySelfPresence, + resolveGatewayProbeSnapshot, + resolveMemoryPluginStatus, + resolveSharedMemoryStatusSnapshot, + type GatewayProbeSnapshot, + type MemoryPluginStatus, + type MemoryStatusSnapshot, +} from "./status.scan.shared.js"; import { getStatusSummary } from "./status.summary.js"; import { getUpdateCheckResult } from "./status.update.js"; -type MemoryStatusSnapshot = MemoryProviderStatus & { - agentId: string; -}; - -type MemoryPluginStatus = { - enabled: boolean; - slot: string | null; - reason?: string; -}; - -function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { - if ( - cfg.agents?.defaults && - Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") - ) { - return true; - } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - return agents.some( - (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), - ); -} - type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; -type GatewayProbeSnapshot = { - gatewayConnection: ReturnType; - remoteUrlMissing: boolean; - gatewayMode: "local" | "remote"; - gatewayProbeAuth: { - token?: string; - password?: string; - }; - gatewayProbeAuthWarning?: string; - gatewayProbe: Awaited> | null; -}; - let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; let statusScanDepsRuntimeModulePromise: @@ -97,54 +64,6 @@ function unwrapDeferredResult(result: DeferredResult): T { return result.value; } -function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStatus { - const pluginsEnabled = cfg.plugins?.enabled !== false; - if (!pluginsEnabled) { - return { enabled: false, slot: null, reason: "plugins disabled" }; - } - const raw = typeof cfg.plugins?.slots?.memory === "string" ? cfg.plugins.slots.memory.trim() : ""; - if (raw && raw.toLowerCase() === "none") { - return { enabled: false, slot: null, reason: 'plugins.slots.memory="none"' }; - } - return { enabled: true, slot: raw || "memory-core" }; -} - -async function resolveGatewayProbeSnapshot(params: { - cfg: OpenClawConfig; - opts: { timeoutMs?: number; all?: boolean }; -}): Promise { - const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); - const isRemoteMode = params.cfg.gateway?.mode === "remote"; - const remoteUrlRaw = - typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); - const gatewayMode = isRemoteMode ? "remote" : "local"; - const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg); - let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; - const gatewayProbe = remoteUrlMissing - ? null - : await probeGateway({ - 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 - ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` - : gatewayProbeAuthWarning; - gatewayProbeAuthWarning = undefined; - } - return { - gatewayConnection, - remoteUrlMissing, - gatewayMode, - gatewayProbeAuth: gatewayProbeAuthResolution.auth, - gatewayProbeAuthWarning, - gatewayProbe, - }; -} - async function resolveChannelsStatus(params: { cfg: OpenClawConfig; gatewayReachable: boolean; @@ -173,7 +92,7 @@ export type StatusScanResult = { tailscaleDns: string | null; tailscaleHttpsUrl: string | null; update: Awaited>; - gatewayConnection: ReturnType; + gatewayConnection: GatewayProbeSnapshot["gatewayConnection"]; remoteUrlMissing: boolean; gatewayMode: "local" | "remote"; gatewayProbeAuth: { @@ -181,7 +100,7 @@ export type StatusScanResult = { password?: string; }; gatewayProbeAuthWarning?: string; - gatewayProbe: Awaited> | null; + gatewayProbe: GatewayProbeSnapshot["gatewayProbe"]; gatewayReachable: boolean; gatewaySelf: ReturnType; channelIssues: ReturnType; @@ -197,34 +116,14 @@ async function resolveMemoryStatusSnapshot(params: { agentStatus: Awaited>; memoryPlugin: MemoryPluginStatus; }): Promise { - const { cfg, agentStatus, memoryPlugin } = params; - if (!memoryPlugin.enabled) { - return null; - } - if (memoryPlugin.slot !== "memory-core") { - return null; - } - const agentId = agentStatus.defaultId ?? "main"; - const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); - if (!resolvedMemory) { - return null; - } - const shouldInspectStore = - hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); - if (!shouldInspectStore) { - return null; - } const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); - const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - if (!manager) { - return null; - } - try { - await manager.probeVectorAvailability(); - } catch {} - const status = manager.status(); - await manager.close?.().catch(() => {}); - return { agentId, ...status }; + return await resolveSharedMemoryStatusSnapshot({ + cfg: params.cfg, + agentStatus: params.agentStatus, + memoryPlugin: params.memoryPlugin, + resolveMemoryConfig: resolveMemorySearchConfig, + getMemorySearchManager, + }); } async function scanStatusJsonFast(opts: { @@ -274,10 +173,11 @@ async function scanStatusJsonFast(opts: { gatewayProbePromise, summaryPromise, ]); - const tailscaleHttpsUrl = - tailscaleMode !== "off" && tailscaleDns - ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` - : null; + const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ + tailscaleMode, + tailscaleDns, + controlUiBasePath: cfg.gateway?.controlUi?.basePath, + }); const { gatewayConnection, @@ -376,10 +276,11 @@ export async function scanStatus( progress.setLabel("Checking Tailscale…"); const tailscaleDns = await tailscaleDnsPromise; - const tailscaleHttpsUrl = - tailscaleMode !== "off" && tailscaleDns - ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` - : null; + const tailscaleHttpsUrl = buildTailscaleHttpsUrl({ + tailscaleMode, + tailscaleDns, + controlUiBasePath: cfg.gateway?.controlUi?.basePath, + }); progress.tick(); progress.setLabel("Checking for updates…"); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 4ff03af91e0..396634cb088 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -7,6 +7,7 @@ import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -132,24 +133,6 @@ function asSchemaObject(value: unknown): JsonSchemaObject | null { return value as JsonSchemaObject; } -function schemaHasChildren(schema: JsonSchemaObject): boolean { - if (schema.properties && Object.keys(schema.properties).length > 0) { - return true; - } - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - return true; - } - if (Array.isArray(schema.items)) { - return schema.items.some((entry) => typeof entry === "object" && entry !== null); - } - for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) { - if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) { - return true; - } - } - return Boolean(schema.items && typeof schema.items === "object"); -} - function splitHintLookupPath(path: string): string[] { const normalized = normalizeBaselinePath(path); return normalized ? normalized.split(".").filter(Boolean) : []; @@ -159,45 +142,11 @@ function resolveUiHintMatch( uiHints: ConfigSchemaResponse["uiHints"], path: string, ): ConfigSchemaResponse["uiHints"][string] | undefined { - const targetParts = splitHintLookupPath(path); - let bestMatch: - | { - hint: ConfigSchemaResponse["uiHints"][string]; - wildcardCount: number; - } - | undefined; - - for (const [hintPath, hint] of Object.entries(uiHints)) { - const hintParts = splitHintLookupPath(hintPath); - if (hintParts.length !== targetParts.length) { - continue; - } - - let wildcardCount = 0; - let matches = true; - for (let index = 0; index < hintParts.length; index += 1) { - const hintPart = hintParts[index]; - const targetPart = targetParts[index]; - if (hintPart === targetPart) { - continue; - } - if (hintPart === "*") { - wildcardCount += 1; - continue; - } - matches = false; - break; - } - - if (!matches) { - continue; - } - if (!bestMatch || wildcardCount < bestMatch.wildcardCount) { - bestMatch = { hint, wildcardCount }; - } - } - - return bestMatch?.hint; + return findWildcardHintMatch({ + uiHints, + path, + splitPath: splitHintLookupPath, + })?.hint; } function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined { diff --git a/src/config/schema.shared.test.ts b/src/config/schema.shared.test.ts new file mode 100644 index 00000000000..48820fbf029 --- /dev/null +++ b/src/config/schema.shared.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; + +describe("schema.shared", () => { + it("prefers the most specific wildcard hint match", () => { + const match = findWildcardHintMatch({ + uiHints: { + "channels.*.token": { label: "wildcard" }, + "channels.telegram.token": { label: "telegram" }, + }, + path: "channels.telegram.token", + splitPath: (value) => value.split("."), + }); + + expect(match).toEqual({ + path: "channels.telegram.token", + hint: { label: "telegram" }, + }); + }); + + it("treats branch schemas as having children", () => { + expect( + schemaHasChildren({ + oneOf: [{ type: "string" }, { properties: { token: { type: "string" } } }], + }), + ).toBe(true); + }); +}); diff --git a/src/config/schema.shared.ts b/src/config/schema.shared.ts new file mode 100644 index 00000000000..148d5b3fb86 --- /dev/null +++ b/src/config/schema.shared.ts @@ -0,0 +1,73 @@ +type JsonSchemaObject = { + properties?: Record; + additionalProperties?: JsonSchemaObject | boolean; + items?: JsonSchemaObject | JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + allOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +}; + +export function schemaHasChildren(schema: JsonSchemaObject): boolean { + if (schema.properties && Object.keys(schema.properties).length > 0) { + return true; + } + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + return true; + } + if (Array.isArray(schema.items)) { + return schema.items.some((entry) => typeof entry === "object" && entry !== null); + } + for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) { + if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) { + return true; + } + } + return Boolean(schema.items && typeof schema.items === "object"); +} + +export function findWildcardHintMatch(params: { + uiHints: Record; + path: string; + splitPath: (path: string) => string[]; +}): { path: string; hint: T } | null { + const targetParts = params.splitPath(params.path); + let bestMatch: + | { + path: string; + hint: T; + wildcardCount: number; + } + | undefined; + + for (const [hintPath, hint] of Object.entries(params.uiHints)) { + const hintParts = params.splitPath(hintPath); + if (hintParts.length !== targetParts.length) { + continue; + } + + let wildcardCount = 0; + let matches = true; + for (let index = 0; index < hintParts.length; index += 1) { + const hintPart = hintParts[index]; + const targetPart = targetParts[index]; + if (hintPart === targetPart) { + continue; + } + if (hintPart === "*") { + wildcardCount += 1; + continue; + } + matches = false; + break; + } + + if (!matches) { + continue; + } + if (!bestMatch || wildcardCount < bestMatch.wildcardCount) { + bestMatch = { path: hintPath, hint, wildcardCount }; + } + } + + return bestMatch ? { path: bestMatch.path, hint: bestMatch.hint } : null; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 83227a375d5..c81e08ea3c3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -3,6 +3,7 @@ import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; import { applyDerivedTags } from "./schema.tags.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -500,52 +501,11 @@ function resolveUiHintMatch( uiHints: ConfigUiHints, path: string, ): { path: string; hint: ConfigUiHint } | null { - const targetParts = splitLookupPath(path); - let best: { path: string; hint: ConfigUiHint; wildcardCount: number } | null = null; - - for (const [hintPath, hint] of Object.entries(uiHints)) { - const hintParts = splitLookupPath(hintPath); - if (hintParts.length !== targetParts.length) { - continue; - } - - let wildcardCount = 0; - let matches = true; - for (let index = 0; index < hintParts.length; index += 1) { - const hintPart = hintParts[index]; - const targetPart = targetParts[index]; - if (hintPart === targetPart) { - continue; - } - if (hintPart === "*") { - wildcardCount += 1; - continue; - } - matches = false; - break; - } - if (!matches) { - continue; - } - if (!best || wildcardCount < best.wildcardCount) { - best = { path: hintPath, hint, wildcardCount }; - } - } - - return best ? { path: best.path, hint: best.hint } : null; -} - -function schemaHasChildren(schema: JsonSchemaObject): boolean { - if (schema.properties && Object.keys(schema.properties).length > 0) { - return true; - } - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - return true; - } - if (Array.isArray(schema.items)) { - return schema.items.some((entry) => typeof entry === "object" && entry !== null); - } - return Boolean(schema.items && typeof schema.items === "object"); + return findWildcardHintMatch({ + uiHints, + path, + splitPath: splitLookupPath, + }); } function resolveItemsSchema(schema: JsonSchemaObject, index?: number): JsonSchemaObject | null { diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 862aaf95f06..3917f49d301 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -11,7 +11,7 @@ import { summarizeDeviceTokens, } from "../../infra/device-pairing.js"; import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js"; -import { roleScopesAllow } from "../../shared/operator-scope-compat.js"; +import { resolveMissingRequestedScope } from "../../shared/operator-scope-compat.js"; import { ErrorCodes, errorShape, @@ -37,25 +37,6 @@ function redactPairedDevice( }; } -function resolveMissingRequestedScope(params: { - role: string; - requestedScopes: readonly string[]; - callerScopes: readonly string[]; -}): string | null { - for (const scope of params.requestedScopes) { - if ( - !roleScopesAllow({ - role: params.role, - requestedScopes: [scope], - allowedScopes: params.callerScopes, - }) - ) { - return scope; - } - } - return null; -} - function logDeviceTokenRotationDenied(params: { log: { warn: (message: string) => void }; deviceId: string; @@ -234,7 +215,7 @@ export const deviceHandlers: GatewayRequestHandlers = { const missingScope = resolveMissingRequestedScope({ role, requestedScopes, - callerScopes, + allowedScopes: callerScopes, }); if (missingScope) { logDeviceTokenRotationDenied({ diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 81d479cbbd6..383e8498a28 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -4,7 +4,10 @@ import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, type ExecApprovalDecision, } from "../../infra/exec-approvals.js"; -import { buildSystemRunApprovalBinding } from "../../infra/system-run-approval-binding.js"; +import { + buildSystemRunApprovalBinding, + buildSystemRunApprovalEnvBinding, +} from "../../infra/system-run-approval-binding.js"; import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { @@ -107,6 +110,7 @@ export function createExecApprovalHandlers( ); return; } + const envBinding = buildSystemRunApprovalEnvBinding(p.env); const systemRunBinding = host === "node" ? buildSystemRunApprovalBinding({ @@ -132,7 +136,7 @@ export function createExecApprovalHandlers( ? undefined : sanitizeExecApprovalDisplayText(approvalContext.commandPreview), commandArgv: host === "node" ? undefined : effectiveCommandArgv, - envKeys: systemRunBinding?.envKeys?.length ? systemRunBinding.envKeys : undefined, + envKeys: envBinding.envKeys.length > 0 ? envBinding.envKeys : undefined, systemRunBinding: systemRunBinding?.binding ?? null, systemRunPlan: approvalContext.plan, cwd: effectiveCwd ?? null, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index bd42485f4f8..a7afcb60f5f 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; -import { buildSystemRunApprovalBinding } from "../../infra/system-run-approval-binding.js"; +import { + buildSystemRunApprovalBinding, + buildSystemRunApprovalEnvBinding, +} from "../../infra/system-run-approval-binding.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js"; @@ -583,6 +586,31 @@ describe("exec approval handlers", () => { ); }); + it("stores sorted env keys for gateway approvals without node-only binding", async () => { + const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + host: "gateway", + nodeId: undefined, + systemRunPlan: undefined, + env: { + Z_VAR: "z", + A_VAR: "a", + }, + }, + }); + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const request = (requested?.payload as { request?: Record })?.request ?? {}; + expect(request["envKeys"]).toEqual( + buildSystemRunApprovalEnvBinding({ A_VAR: "a", Z_VAR: "z" }).envKeys, + ); + expect(request["systemRunBinding"]).toBeNull(); + }); + it("prefers systemRunPlan canonical command/cwd when present", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ diff --git a/src/image-generation/providers/google.live.test.ts b/src/image-generation/providers/google.live.test.ts new file mode 100644 index 00000000000..dcf2ddd1108 --- /dev/null +++ b/src/image-generation/providers/google.live.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { isTruthyEnvValue } from "../../infra/env.js"; +import { buildGoogleImageGenerationProvider } from "./google.js"; + +const LIVE = + isTruthyEnvValue(process.env.GOOGLE_LIVE_TEST) || + isTruthyEnvValue(process.env.LIVE) || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const HAS_KEY = Boolean(process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim()); +const MODEL = + process.env.GOOGLE_IMAGE_GENERATION_MODEL?.trim() || + process.env.GEMINI_IMAGE_GENERATION_MODEL?.trim() || + "gemini-3.1-flash-image-preview"; +const BASE_URL = process.env.GOOGLE_IMAGE_BASE_URL?.trim(); + +const describeLive = LIVE && HAS_KEY ? describe : describe.skip; + +function buildLiveConfig(): OpenClawConfig { + if (!BASE_URL) { + return {}; + } + return { + models: { + providers: { + google: { + baseUrl: BASE_URL, + }, + }, + }, + } as unknown as OpenClawConfig; +} + +describeLive("google image-generation live", () => { + it("generates a real image", async () => { + const provider = buildGoogleImageGenerationProvider(); + const result = await provider.generateImage({ + provider: "google", + model: MODEL, + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + cfg: buildLiveConfig(), + size: "1024x1024", + }); + + expect(result.model).toBeTruthy(); + expect(result.images.length).toBeGreaterThan(0); + expect(result.images[0]?.mimeType.startsWith("image/")).toBe(true); + expect(result.images[0]?.buffer.byteLength).toBeGreaterThan(512); + }, 120_000); +}); diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts new file mode 100644 index 00000000000..83f7e565a80 --- /dev/null +++ b/src/image-generation/providers/google.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as modelAuth from "../../agents/model-auth.js"; +import { buildGoogleImageGenerationProvider } from "./google.js"; + +describe("Google image-generation provider", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("generates image buffers from the Gemini generateContent API", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { text: "generated" }, + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + const result = await provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a cat", + cfg: {}, + size: "1536x1024", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [{ text: "draw a cat" }], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + imageConfig: { + aspectRatio: "3:2", + imageSize: "2K", + }, + }, + }), + }), + ); + expect(result).toEqual({ + images: [ + { + buffer: Buffer.from("png-data"), + mimeType: "image/png", + fileName: "image-1.png", + }, + ], + model: "gemini-3.1-flash-image-preview", + }); + }); + + it("accepts OAuth JSON auth and inline_data responses", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: JSON.stringify({ token: "oauth-token" }), + source: "profile", + mode: "token", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + inline_data: { + mime_type: "image/jpeg", + data: Buffer.from("jpg-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + const result = await provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a dog", + cfg: {}, + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + const [, init] = fetchMock.mock.calls[0]; + expect(new Headers(init.headers).get("authorization")).toBe("Bearer oauth-token"); + expect(result).toEqual({ + images: [ + { + buffer: Buffer.from("jpg-data"), + mimeType: "image/jpeg", + fileName: "image-1.jpg", + }, + ], + model: "gemini-3.1-flash-image-preview", + }); + }); +}); diff --git a/src/image-generation/providers/google.ts b/src/image-generation/providers/google.ts new file mode 100644 index 00000000000..0519aef7bc3 --- /dev/null +++ b/src/image-generation/providers/google.ts @@ -0,0 +1,159 @@ +import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; +import { normalizeGoogleModelId } from "../../agents/model-id-normalization.js"; +import { parseGeminiAuth } from "../../infra/gemini-auth.js"; +import { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, +} from "../../media-understanding/providers/shared.js"; +import type { ImageGenerationProviderPlugin } from "../../plugins/types.js"; + +const DEFAULT_GOOGLE_IMAGE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview"; +const DEFAULT_OUTPUT_MIME = "image/png"; +const DEFAULT_ASPECT_RATIO = "1:1"; + +type GoogleInlineDataPart = { + mimeType?: string; + mime_type?: string; + data?: string; +}; + +type GoogleGenerateImageResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + inlineData?: GoogleInlineDataPart; + inline_data?: GoogleInlineDataPart; + }>; + }; + }>; +}; + +function resolveGoogleBaseUrl(cfg: Parameters[0]["cfg"]): string { + const direct = cfg?.models?.providers?.google?.baseUrl?.trim(); + return direct || DEFAULT_GOOGLE_IMAGE_BASE_URL; +} + +function normalizeGoogleImageModel(model: string | undefined): string { + const trimmed = model?.trim(); + return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL); +} + +function mapSizeToImageConfig( + size: string | undefined, +): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined { + const trimmed = size?.trim(); + if (!trimmed) { + return { aspectRatio: DEFAULT_ASPECT_RATIO }; + } + + const normalized = trimmed.toLowerCase(); + const mapping = new Map([ + ["1024x1024", "1:1"], + ["1024x1536", "2:3"], + ["1536x1024", "3:2"], + ["1024x1792", "9:16"], + ["1792x1024", "16:9"], + ]); + const aspectRatio = mapping.get(normalized); + + const [widthRaw, heightRaw] = normalized.split("x"); + const width = Number.parseInt(widthRaw ?? "", 10); + const height = Number.parseInt(heightRaw ?? "", 10); + const longestEdge = Math.max(width, height); + const imageSize = longestEdge >= 3072 ? "4K" : longestEdge >= 1536 ? "2K" : undefined; + + if (!aspectRatio && !imageSize) { + return undefined; + } + + return { + ...(aspectRatio ? { aspectRatio } : {}), + ...(imageSize ? { imageSize } : {}), + }; +} + +export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlugin { + return { + id: "google", + label: "Google", + async generateImage(req) { + const auth = await resolveApiKeyForProvider({ + provider: "google", + cfg: req.cfg, + agentDir: req.agentDir, + }); + if (!auth.apiKey) { + throw new Error("Google API key missing"); + } + + const model = normalizeGoogleImageModel(req.model); + const baseUrl = normalizeBaseUrl( + resolveGoogleBaseUrl(req.cfg), + DEFAULT_GOOGLE_IMAGE_BASE_URL, + ); + const allowPrivate = Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()); + const authHeaders = parseGeminiAuth(auth.apiKey); + const headers = new Headers(authHeaders.headers); + const imageConfig = mapSizeToImageConfig(req.size); + + const { response: res, release } = await postJsonRequest({ + url: `${baseUrl}/models/${model}:generateContent`, + headers, + body: { + contents: [ + { + role: "user", + parts: [{ text: req.prompt }], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + ...(imageConfig ? { imageConfig } : {}), + }, + }, + timeoutMs: 60_000, + fetchFn: fetch, + allowPrivateNetwork: allowPrivate, + }); + + try { + await assertOkOrThrowHttpError(res, "Google image generation failed"); + + const payload = (await res.json()) as GoogleGenerateImageResponse; + let imageIndex = 0; + const images = (payload.candidates ?? []) + .flatMap((candidate) => candidate.content?.parts ?? []) + .map((part) => { + const inline = part.inlineData ?? part.inline_data; + const data = inline?.data?.trim(); + if (!data) { + return null; + } + const mimeType = inline?.mimeType ?? inline?.mime_type ?? DEFAULT_OUTPUT_MIME; + const extension = mimeType.includes("jpeg") ? "jpg" : (mimeType.split("/")[1] ?? "png"); + imageIndex += 1; + return { + buffer: Buffer.from(data, "base64"), + mimeType, + fileName: `image-${imageIndex}.${extension}`, + }; + }) + .filter((entry): entry is NonNullable => entry !== null); + + if (images.length === 0) { + throw new Error("Google image generation response missing image data"); + } + + return { + images, + model, + }; + } finally { + await release(); + } + }, + }; +} diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index e6cf9259a66..063834a17de 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; -import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js"; import { createAsyncLock, pruneExpiredPending, @@ -256,25 +256,6 @@ function scopesWithinApprovedDeviceBaseline(params: { }); } -function resolveMissingRequestedScope(params: { - role: string; - requestedScopes: readonly string[]; - callerScopes: readonly string[]; -}): string | null { - for (const scope of params.requestedScopes) { - if ( - !roleScopesAllow({ - role: params.role, - requestedScopes: [scope], - allowedScopes: params.callerScopes, - }) - ) { - return scope; - } - } - return null; -} - export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); @@ -377,7 +358,7 @@ export async function approveDevicePairing( const missingScope = resolveMissingRequestedScope({ role: pending.role, requestedScopes: normalizeDeviceAuthScopes(pending.scopes), - callerScopes: options.callerScopes, + allowedScopes: options.callerScopes, }); if (missingScope) { return { status: "forbidden", missingScope }; diff --git a/src/infra/outbound/base-session-key.ts b/src/infra/outbound/base-session-key.ts new file mode 100644 index 00000000000..af3b3da1cdd --- /dev/null +++ b/src/infra/outbound/base-session-key.ts @@ -0,0 +1,19 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-route.js"; + +export function buildOutboundBaseSessionKey(params: { + cfg: OpenClawConfig; + agentId: string; + channel: string; + accountId?: string | null; + peer: RoutePeer; +}): string { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + accountId: params.accountId, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index a65e2da313e..274e2c80397 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -4,9 +4,10 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { recordSessionMetaFromInbound, resolveStorePath } from "../../config/sessions.js"; -import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-route.js"; +import type { RoutePeer } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import { buildOutboundBaseSessionKey } from "./base-session-key.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; import { normalizeOutboundThreadId } from "./thread-id.js"; @@ -76,14 +77,7 @@ function buildBaseSessionKey(params: { accountId?: string | null; peer: RoutePeer; }): string { - return buildAgentSessionKey({ - agentId: params.agentId, - channel: params.channel, - accountId: params.accountId, - peer: params.peer, - dmScope: params.cfg.session?.dmScope ?? "main", - identityLinks: params.cfg.session?.identityLinks, - }); + return buildOutboundBaseSessionKey(params); } function resolveWhatsAppSession( diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index dc62cece821..982ffbc8be5 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, @@ -12,9 +9,9 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -44,27 +41,6 @@ function parseGoogleUsageToken(apiKey: string): string { return apiKey; } -function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined { - try { - const authPath = path.join( - resolveRequiredHomeDir(env, os.homedir), - ".pi", - "agent", - "auth.json", - ); - if (!fs.existsSync(authPath)) { - return undefined; - } - const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record< - string, - { access?: string } - >; - return parsed["z-ai"]?.access || parsed.zai?.access; - } catch { - return undefined; - } -} - function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -225,7 +201,7 @@ async function resolveProviderUsageAuthFallback(params: { if (apiKey) { return { provider: "zai", token: apiKey }; } - const legacyToken = resolveLegacyZaiUsageToken(params.state.env); + const legacyToken = resolveLegacyPiAgentAccessToken(params.state.env, ["z-ai", "zai"]); return legacyToken ? { provider: "zai", token: legacyToken } : null; } case "minimax": { diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index 048352a183d..4f575f197ff 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -1,5 +1,13 @@ +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 { clampPercent, resolveUsageProviderId, withTimeout } from "./provider-usage.shared.js"; +import { + clampPercent, + resolveLegacyPiAgentAccessToken, + resolveUsageProviderId, + withTimeout, +} from "./provider-usage.shared.js"; describe("provider-usage.shared", () => { afterEach(() => { @@ -52,4 +60,34 @@ describe("provider-usage.shared", () => { expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); }); + + it("reads legacy pi auth tokens for known provider aliases", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-")); + await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true }); + await fs.writeFile( + path.join(home, ".pi", "agent", "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`, + "utf8", + ); + + try { + expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBe( + "legacy-zai-key", + ); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); + + it("returns undefined for invalid legacy pi auth files", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-usage-")); + await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true }); + await fs.writeFile(path.join(home, ".pi", "agent", "auth.json"), "{not-json", "utf8"); + + try { + expect(resolveLegacyPiAgentAccessToken({ HOME: home }, ["z-ai", "zai"])).toBeUndefined(); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 6fa823db630..b801da4824c 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -1,4 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { normalizeProviderId } from "../agents/model-selection.js"; +import { resolveRequiredHomeDir } from "./home-dir.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export const DEFAULT_TIMEOUT_MS = 5000; @@ -59,3 +63,33 @@ export const withTimeout = async (work: Promise, ms: number, fallback: T): } } }; + +export function resolveLegacyPiAgentAccessToken( + env: NodeJS.ProcessEnv, + providerIds: string[], +): string | undefined { + try { + const authPath = path.join( + resolveRequiredHomeDir(env, os.homedir), + ".pi", + "agent", + "auth.json", + ); + if (!fs.existsSync(authPath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record< + string, + { access?: string } + >; + for (const providerId of providerIds) { + const token = parsed[providerId]?.access; + if (typeof token === "string" && token.trim()) { + return token; + } + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index fda11949c4e..a6c842e79d5 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -45,3 +45,28 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { + DEFAULT_SECRET_FILE_MAX_BYTES, + loadSecretFileSync, + readSecretFileSync, + tryReadSecretFileSync, +} from "../infra/secret-file.js"; +export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js"; + +export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; +export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; + +export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; +export type { + TailscaleStatusCommandResult, + TailscaleStatusCommandRunner, +} from "../shared/tailscale-status.js"; +export { + buildAgentSessionKey, + type RoutePeer, + type RoutePeerKind, +} from "../routing/resolve-route.js"; +export { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; +export { normalizeOutboundThreadId } from "../infra/outbound/thread-id.js"; +export { resolveThreadSessionKeys } from "../routing/session-key.js"; diff --git a/src/plugin-sdk/image-generation.ts b/src/plugin-sdk/image-generation.ts index 9ca98074743..d9afa8b3a3d 100644 --- a/src/plugin-sdk/image-generation.ts +++ b/src/plugin-sdk/image-generation.ts @@ -7,4 +7,5 @@ export type { ImageGenerationResult, } from "../image-generation/types.js"; +export { buildGoogleImageGenerationProvider } from "../image-generation/providers/google.js"; export { buildOpenAIImageGenerationProvider } from "../image-generation/providers/openai.js"; diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts index 33757596965..9b63a53ea93 100644 --- a/src/plugin-sdk/provider-usage.ts +++ b/src/plugin-sdk/provider-usage.ts @@ -13,7 +13,11 @@ export { fetchMinimaxUsage, fetchZaiUsage, } from "../infra/provider-usage.fetch.js"; -export { clampPercent, PROVIDER_LABELS } from "../infra/provider-usage.shared.js"; +export { + clampPercent, + PROVIDER_LABELS, + resolveLegacyPiAgentAccessToken, +} from "../infra/provider-usage.shared.js"; export { buildUsageErrorSnapshot, buildUsageHttpErrorSnapshot, diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index bd4e5283c97..065fbfeed9c 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -24,6 +24,7 @@ export { normalizeE164, pathExists } from "../utils.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, + createEnvPatchedAccountSetupAdapter, createPatchedAccountSetupAdapter, migrateBaseNameToDefaultAccount, patchScopedAccountConfig, diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index 981eb9fd3a6..b5645035f5d 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -46,11 +46,11 @@ function normalizePathList(value: unknown): string[] { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } -function normalizeBundlePathList(value: unknown): string[] { +export function normalizeBundlePathList(value: unknown): string[] { return Array.from(new Set(normalizePathList(value))); } -function mergeBundlePathLists(...groups: string[][]): string[] { +export function mergeBundlePathLists(...groups: string[][]): string[] { const merged: string[] = []; const seen = new Set(); for (const group of groups) { diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 29bd2b3a6c9..fbd733d9695 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -8,6 +8,8 @@ import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, + mergeBundlePathLists, + normalizeBundlePathList, } from "./bundle-manifest.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -41,32 +43,6 @@ const MANIFEST_PATH_BY_FORMAT: Record = { }; const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"; -function normalizePathList(value: unknown): string[] { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - -function mergeUniquePathLists(...groups: string[][]): string[] { - const merged: string[] = []; - const seen = new Set(); - for (const group of groups) { - for (const entry of group) { - if (seen.has(entry)) { - continue; - } - seen.add(entry); - merged.push(entry); - } - } - return merged; -} - function readPluginJsonObject(params: { rootDir: string; relativePath: string; @@ -103,12 +79,12 @@ function resolveBundleMcpConfigPaths(params: { rootDir: string; bundleFormat: PluginBundleFormat; }): string[] { - const declared = normalizePathList(params.raw.mcpServers); + const declared = normalizeBundlePathList(params.raw.mcpServers); const defaults = fs.existsSync(path.join(params.rootDir, ".mcp.json")) ? [".mcp.json"] : []; if (params.bundleFormat === "claude") { - return mergeUniquePathLists(defaults, declared); + return mergeBundlePathLists(defaults, declared); } - return mergeUniquePathLists(defaults, declared); + return mergeBundlePathLists(defaults, declared); } export function extractMcpServerMap(raw: unknown): Record { diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 762612cc45a..997aa560579 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -165,6 +165,7 @@ describe("plugin contract registry", () => { }); it("keeps bundled image-generation ownership explicit", () => { + expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]); expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]); }); @@ -180,6 +181,13 @@ describe("plugin contract registry", () => { }); it("tracks speech registrations on bundled provider plugins", () => { + expect(findRegistrationForPlugin("google")).toMatchObject({ + providerIds: ["google", "google-gemini-cli"], + speechProviderIds: [], + mediaUnderstandingProviderIds: ["google"], + imageGenerationProviderIds: ["google"], + webSearchProviderIds: ["gemini"], + }); expect(findRegistrationForPlugin("openai")).toMatchObject({ providerIds: ["openai", "openai-codex"], speechProviderIds: ["openai"], @@ -245,6 +253,9 @@ describe("plugin contract registry", () => { }); it("keeps bundled image-generation support explicit", () => { + expect(findImageGenerationProviderForPlugin("google").generateImage).toEqual( + expect.any(Function), + ); expect(findImageGenerationProviderForPlugin("openai").generateImage).toEqual( expect.any(Function), ); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index a4d2f815d7b..adedfe57d0c 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -131,7 +131,7 @@ const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [ zaiPlugin, ]; -const bundledImageGenerationPlugins: RegistrablePlugin[] = [openAIPlugin]; +const bundledImageGenerationPlugins: RegistrablePlugin[] = [googlePlugin, openAIPlugin]; function captureRegistrations(plugin: RegistrablePlugin) { const captured = createCapturedPluginRegistration(); diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 073ad01c960..87acf1f8a13 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; import type { ProviderRuntimeModel } from "../types.js"; @@ -514,6 +517,33 @@ describe("provider runtime contract", () => { }); }); + it("falls back to legacy pi auth tokens for usage auth", async () => { + const provider = requireProvider("zai"); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-contract-")); + await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true }); + await fs.writeFile( + path.join(home, ".pi", "agent", "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`, + "utf8", + ); + + try { + await expect( + provider.resolveUsageAuth?.({ + config: {} as never, + env: { HOME: home } as NodeJS.ProcessEnv, + provider: "zai", + resolveApiKeyFromConfigAndStore: () => undefined, + resolveOAuthToken: async () => null, + }), + ).resolves.toEqual({ + token: "legacy-zai-token", + }); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); + it("owns usage snapshot fetching", async () => { const provider = requireProviderContractProvider("zai"); const mockFetch = createProviderUsageFetch(async (url) => { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index ea84b562729..7a6d9d54578 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -219,6 +219,46 @@ describe("discoverOpenClawPlugins", () => { expect(ids).not.toContain("ollama-provider"); }); + it("normalizes bundled speech package ids to canonical plugin ids", async () => { + const stateDir = makeTempDir(); + const extensionsDir = path.join(stateDir, "extensions"); + const elevenlabsDir = path.join(extensionsDir, "elevenlabs-speech-pack"); + const microsoftDir = path.join(extensionsDir, "microsoft-speech-pack"); + + mkdirSafe(path.join(elevenlabsDir, "src")); + mkdirSafe(path.join(microsoftDir, "src")); + + writePluginPackageManifest({ + packageDir: elevenlabsDir, + packageName: "@openclaw/elevenlabs-speech", + extensions: ["./src/index.ts"], + }); + writePluginPackageManifest({ + packageDir: microsoftDir, + packageName: "@openclaw/microsoft-speech", + extensions: ["./src/index.ts"], + }); + + fs.writeFileSync( + path.join(elevenlabsDir, "src", "index.ts"), + "export default function () {}", + "utf-8", + ); + fs.writeFileSync( + path.join(microsoftDir, "src", "index.ts"), + "export default function () {}", + "utf-8", + ); + + const { candidates } = await discoverWithStateDir(stateDir, {}); + + const ids = candidates.map((c) => c.idHint); + expect(ids).toContain("elevenlabs"); + expect(ids).toContain("microsoft"); + expect(ids).not.toContain("elevenlabs-speech"); + expect(ids).not.toContain("microsoft-speech"); + }); + it("treats configured directory paths as plugin packages", async () => { const stateDir = makeTempDir(); const packDir = path.join(stateDir, "packs", "demo-plugin-dir"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 743b0b569f9..24d4765e31b 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -16,6 +16,14 @@ import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); +const CANONICAL_PACKAGE_ID_ALIASES: Record = { + "elevenlabs-speech": "elevenlabs", + "microsoft-speech": "microsoft", + "ollama-provider": "ollama", + "sglang-provider": "sglang", + "vllm-provider": "vllm", +}; + export type PluginCandidate = { idHint: string; source: string; @@ -337,12 +345,7 @@ function deriveIdHint(params: { const unscoped = rawPackageName.includes("/") ? (rawPackageName.split("/").pop() ?? rawPackageName) : rawPackageName; - const canonicalPackageId = - { - "ollama-provider": "ollama", - "sglang-provider": "sglang", - "vllm-provider": "vllm", - }[unscoped] ?? unscoped; + const canonicalPackageId = CANONICAL_PACKAGE_ID_ALIASES[unscoped] ?? unscoped; if (!params.hasMultipleExtensions) { return canonicalPackageId; diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index cffafd6645d..e8e1e2aa163 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -317,20 +317,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, first-claim wins)`); - for (const hook of hooks) { - try { - const handlerResult = await ( - hook.handler as (event: unknown, ctx: unknown) => Promise - )(event, ctx); - if (handlerResult?.handled) { - return handlerResult; - } - } catch (err) { - handleHookError({ hookName, pluginId: hook.pluginId, error: err }); - } - } - - return undefined; + return await runClaimingHooksList(hooks, hookName, event, ctx); } async function runClaimingHookForPlugin< @@ -351,6 +338,18 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp `[hooks] running ${hookName} for ${pluginId} (${hooks.length} handlers, targeted)`, ); + return await runClaimingHooksList(hooks, hookName, event, ctx); + } + + async function runClaimingHooksList< + K extends PluginHookName, + TResult extends { handled: boolean }, + >( + hooks: Array & { pluginId: string }>, + hookName: K, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { for (const hook of hooks) { try { const handlerResult = await ( diff --git a/src/plugins/install.ts b/src/plugins/install.ts index e6b66381970..52ae9ebf2e1 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -198,6 +198,23 @@ function buildFileInstallResult(pluginId: string, targetFile: string): InstallPl }; } +function buildDirectoryInstallResult(params: { + pluginId: string; + targetDir: string; + manifestName?: string; + version?: string; + extensions: string[]; +}): InstallPluginResult { + return { + ok: true, + pluginId: params.pluginId, + targetDir: params.targetDir, + manifestName: params.manifestName, + version: params.version, + extensions: params.extensions, + }; +} + type PackageInstallCommonParams = { extensionsDir?: string; timeoutMs?: number; @@ -234,6 +251,80 @@ function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInsta }; } +async function installPluginDirectoryIntoExtensions(params: { + sourceDir: string; + pluginId: string; + manifestName?: string; + version?: string; + extensions: string[]; + extensionsDir?: string; + logger: PluginInstallLogger; + timeoutMs: number; + mode: "install" | "update"; + dryRun: boolean; + copyErrorPrefix: string; + hasDeps: boolean; + depsLogMessage: string; + afterCopy?: (installedDir: string) => Promise; + nameEncoder?: (pluginId: string) => string; +}): Promise { + const extensionsDir = params.extensionsDir + ? resolveUserPath(params.extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + const targetDirResult = await resolveCanonicalInstallTarget({ + baseDir: extensionsDir, + id: params.pluginId, + invalidNameMessage: "invalid plugin name: path traversal detected", + boundaryLabel: "extensions directory", + nameEncoder: params.nameEncoder, + }); + if (!targetDirResult.ok) { + return { ok: false, error: targetDirResult.error }; + } + const targetDir = targetDirResult.targetDir; + const availability = await ensureInstallTargetAvailable({ + mode: params.mode, + targetDir, + alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`, + }); + if (!availability.ok) { + return availability; + } + + if (params.dryRun) { + return buildDirectoryInstallResult({ + pluginId: params.pluginId, + targetDir, + manifestName: params.manifestName, + version: params.version, + extensions: params.extensions, + }); + } + + const installRes = await installPackageDir({ + sourceDir: params.sourceDir, + targetDir, + mode: params.mode, + timeoutMs: params.timeoutMs, + logger: params.logger, + copyErrorPrefix: params.copyErrorPrefix, + hasDeps: params.hasDeps, + depsLogMessage: params.depsLogMessage, + afterCopy: params.afterCopy, + }); + if (!installRes.ok) { + return installRes; + } + + return buildDirectoryInstallResult({ + pluginId: params.pluginId, + targetDir, + manifestName: params.manifestName, + version: params.version, + extensions: params.extensions, + }); +} + export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { const extensionsBase = extensionsDir ? resolveUserPath(extensionsDir) @@ -308,61 +399,21 @@ async function installBundleFromSourceDir( ); } - const extensionsDir = params.extensionsDir - ? resolveUserPath(params.extensionsDir) - : path.join(CONFIG_DIR, "extensions"); - const targetDirResult = await resolveCanonicalInstallTarget({ - baseDir: extensionsDir, - id: pluginId, - invalidNameMessage: "invalid plugin name: path traversal detected", - boundaryLabel: "extensions directory", - }); - if (!targetDirResult.ok) { - return { ok: false, error: targetDirResult.error }; - } - const targetDir = targetDirResult.targetDir; - const availability = await ensureInstallTargetAvailable({ - mode, - targetDir, - alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`, - }); - if (!availability.ok) { - return availability; - } - - if (dryRun) { - return { - ok: true, - pluginId, - targetDir, - manifestName: manifestRes.manifest.name, - version: manifestRes.manifest.version, - extensions: [], - }; - } - - const installRes = await installPackageDir({ + return await installPluginDirectoryIntoExtensions({ sourceDir: params.sourceDir, - targetDir, - mode, - timeoutMs, + pluginId, + manifestName: manifestRes.manifest.name, + version: manifestRes.manifest.version, + extensions: [], + extensionsDir: params.extensionsDir, logger, + timeoutMs, + mode, + dryRun, copyErrorPrefix: "failed to copy plugin bundle", hasDeps: false, depsLogMessage: "", }); - if (!installRes.ok) { - return installRes; - } - - return { - ok: true, - pluginId, - targetDir, - manifestName: manifestRes.manifest.name, - version: manifestRes.manifest.version, - extensions: [], - }; } async function installPluginFromSourceDir( @@ -514,51 +565,22 @@ async function installPluginFromPackageDir( ); } - const extensionsDir = params.extensionsDir - ? resolveUserPath(params.extensionsDir) - : path.join(CONFIG_DIR, "extensions"); - const targetDirResult = await resolveCanonicalInstallTarget({ - baseDir: extensionsDir, - id: pluginId, - invalidNameMessage: "invalid plugin name: path traversal detected", - boundaryLabel: "extensions directory", - nameEncoder: encodePluginInstallDirName, - }); - if (!targetDirResult.ok) { - return { ok: false, error: targetDirResult.error }; - } - const targetDir = targetDirResult.targetDir; - const availability = await ensureInstallTargetAvailable({ - mode, - targetDir, - alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`, - }); - if (!availability.ok) { - return availability; - } - - if (dryRun) { - return { - ok: true, - pluginId, - targetDir, - manifestName: pkgName || undefined, - version: typeof manifest.version === "string" ? manifest.version : undefined, - extensions, - }; - } - const deps = manifest.dependencies ?? {}; - const hasDeps = Object.keys(deps).length > 0; - const installRes = await installPackageDir({ + return await installPluginDirectoryIntoExtensions({ sourceDir: params.packageDir, - targetDir, - mode, - timeoutMs, + pluginId, + manifestName: pkgName || undefined, + version: typeof manifest.version === "string" ? manifest.version : undefined, + extensions, + extensionsDir: params.extensionsDir, logger, + timeoutMs, + mode, + dryRun, copyErrorPrefix: "failed to copy plugin", - hasDeps, + hasDeps: Object.keys(deps).length > 0, depsLogMessage: "Installing plugin dependencies…", + nameEncoder: encodePluginInstallDirName, afterCopy: async (installedDir) => { for (const entry of extensions) { const resolvedEntry = path.resolve(installedDir, entry); @@ -572,18 +594,6 @@ async function installPluginFromPackageDir( } }, }); - if (!installRes.ok) { - return installRes; - } - - return { - ok: true, - pluginId, - targetDir, - manifestName: pkgName || undefined, - version: typeof manifest.version === "string" ? manifest.version : undefined, - extensions, - }; } export async function installPluginFromArchive( diff --git a/src/plugins/provider-auth-choice.runtime.ts b/src/plugins/provider-auth-choice.runtime.ts index 7c83aa6da3a..cb298d32c83 100644 --- a/src/plugins/provider-auth-choice.runtime.ts +++ b/src/plugins/provider-auth-choice.runtime.ts @@ -1,2 +1,29 @@ -export { resolveProviderPluginChoice, runProviderModelSelectedHook } from "./provider-wizard.js"; -export { resolvePluginProviders } from "./providers.js"; +import { + resolveProviderPluginChoice as resolveProviderPluginChoiceImpl, + runProviderModelSelectedHook as runProviderModelSelectedHookImpl, +} from "./provider-wizard.js"; +import { resolvePluginProviders as resolvePluginProvidersImpl } from "./providers.js"; + +type ResolveProviderPluginChoice = + typeof import("./provider-wizard.js").resolveProviderPluginChoice; +type RunProviderModelSelectedHook = + typeof import("./provider-wizard.js").runProviderModelSelectedHook; +type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; + +export function resolveProviderPluginChoice( + ...args: Parameters +): ReturnType { + return resolveProviderPluginChoiceImpl(...args); +} + +export function runProviderModelSelectedHook( + ...args: Parameters +): ReturnType { + return runProviderModelSelectedHookImpl(...args); +} + +export function resolvePluginProviders( + ...args: Parameters +): ReturnType { + return resolvePluginProvidersImpl(...args); +} diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index d10daac5a35..6a9d9429713 100644 --- a/src/plugins/runtime/runtime-discord-ops.runtime.ts +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -1,21 +1,150 @@ -export { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; -export { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, +import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/src/audit.js"; +import { + listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, + listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, } from "../../../extensions/discord/src/directory-live.js"; -export { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; -export { probeDiscord } from "../../../extensions/discord/src/probe.js"; -export { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; -export { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -export { - createThreadDiscord, - deleteMessageDiscord, - editChannelDiscord, - editMessageDiscord, - pinMessageDiscord, - sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - sendTypingDiscord, - unpinMessageDiscord, +import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/src/monitor.js"; +import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/src/probe.js"; +import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/src/resolve-channels.js"; +import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/src/resolve-users.js"; +import { + createThreadDiscord as createThreadDiscordImpl, + deleteMessageDiscord as deleteMessageDiscordImpl, + editChannelDiscord as editChannelDiscordImpl, + editMessageDiscord as editMessageDiscordImpl, + pinMessageDiscord as pinMessageDiscordImpl, + sendDiscordComponentMessage as sendDiscordComponentMessageImpl, + sendMessageDiscord as sendMessageDiscordImpl, + sendPollDiscord as sendPollDiscordImpl, + sendTypingDiscord as sendTypingDiscordImpl, + unpinMessageDiscord as unpinMessageDiscordImpl, } from "../../../extensions/discord/src/send.js"; + +type AuditDiscordChannelPermissions = + typeof import("../../../extensions/discord/src/audit.js").auditDiscordChannelPermissions; +type ListDiscordDirectoryGroupsLive = + typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryGroupsLive; +type ListDiscordDirectoryPeersLive = + typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryPeersLive; +type MonitorDiscordProvider = + typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider; +type ProbeDiscord = typeof import("../../../extensions/discord/src/probe.js").probeDiscord; +type ResolveDiscordChannelAllowlist = + typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist; +type ResolveDiscordUserAllowlist = + typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist; +type CreateThreadDiscord = + typeof import("../../../extensions/discord/src/send.js").createThreadDiscord; +type DeleteMessageDiscord = + typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord; +type EditChannelDiscord = + typeof import("../../../extensions/discord/src/send.js").editChannelDiscord; +type EditMessageDiscord = + typeof import("../../../extensions/discord/src/send.js").editMessageDiscord; +type PinMessageDiscord = typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord; +type SendDiscordComponentMessage = + typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage; +type SendMessageDiscord = + typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord; +type SendPollDiscord = typeof import("../../../extensions/discord/src/send.js").sendPollDiscord; +type SendTypingDiscord = typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord; +type UnpinMessageDiscord = + typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord; + +export function auditDiscordChannelPermissions( + ...args: Parameters +): ReturnType { + return auditDiscordChannelPermissionsImpl(...args); +} + +export function listDiscordDirectoryGroupsLive( + ...args: Parameters +): ReturnType { + return listDiscordDirectoryGroupsLiveImpl(...args); +} + +export function listDiscordDirectoryPeersLive( + ...args: Parameters +): ReturnType { + return listDiscordDirectoryPeersLiveImpl(...args); +} + +export function monitorDiscordProvider( + ...args: Parameters +): ReturnType { + return monitorDiscordProviderImpl(...args); +} + +export function probeDiscord(...args: Parameters): ReturnType { + return probeDiscordImpl(...args); +} + +export function resolveDiscordChannelAllowlist( + ...args: Parameters +): ReturnType { + return resolveDiscordChannelAllowlistImpl(...args); +} + +export function resolveDiscordUserAllowlist( + ...args: Parameters +): ReturnType { + return resolveDiscordUserAllowlistImpl(...args); +} + +export function createThreadDiscord( + ...args: Parameters +): ReturnType { + return createThreadDiscordImpl(...args); +} + +export function deleteMessageDiscord( + ...args: Parameters +): ReturnType { + return deleteMessageDiscordImpl(...args); +} + +export function editChannelDiscord( + ...args: Parameters +): ReturnType { + return editChannelDiscordImpl(...args); +} + +export function editMessageDiscord( + ...args: Parameters +): ReturnType { + return editMessageDiscordImpl(...args); +} + +export function pinMessageDiscord( + ...args: Parameters +): ReturnType { + return pinMessageDiscordImpl(...args); +} + +export function sendDiscordComponentMessage( + ...args: Parameters +): ReturnType { + return sendDiscordComponentMessageImpl(...args); +} + +export function sendMessageDiscord( + ...args: Parameters +): ReturnType { + return sendMessageDiscordImpl(...args); +} + +export function sendPollDiscord(...args: Parameters): ReturnType { + return sendPollDiscordImpl(...args); +} + +export function sendTypingDiscord( + ...args: Parameters +): ReturnType { + return sendTypingDiscordImpl(...args); +} + +export function unpinMessageDiscord( + ...args: Parameters +): ReturnType { + return unpinMessageDiscordImpl(...args); +} diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index e22662c3b7f..b01568bc491 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,10 +1,70 @@ -export { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, +import { + listSlackDirectoryGroupsLive as listSlackDirectoryGroupsLiveImpl, + listSlackDirectoryPeersLive as listSlackDirectoryPeersLiveImpl, } from "../../../extensions/slack/src/directory-live.js"; -export { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; -export { probeSlack } from "../../../extensions/slack/src/probe.js"; -export { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; -export { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; -export { sendMessageSlack } from "../../../extensions/slack/src/send.js"; -export { handleSlackAction } from "../../agents/tools/slack-actions.js"; +import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/src/index.js"; +import { probeSlack as probeSlackImpl } from "../../../extensions/slack/src/probe.js"; +import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/src/resolve-channels.js"; +import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/src/resolve-users.js"; +import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/src/send.js"; +import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.js"; + +type ListSlackDirectoryGroupsLive = + typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive; +type ListSlackDirectoryPeersLive = + typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryPeersLive; +type MonitorSlackProvider = + typeof import("../../../extensions/slack/src/index.js").monitorSlackProvider; +type ProbeSlack = typeof import("../../../extensions/slack/src/probe.js").probeSlack; +type ResolveSlackChannelAllowlist = + typeof import("../../../extensions/slack/src/resolve-channels.js").resolveSlackChannelAllowlist; +type ResolveSlackUserAllowlist = + typeof import("../../../extensions/slack/src/resolve-users.js").resolveSlackUserAllowlist; +type SendMessageSlack = typeof import("../../../extensions/slack/src/send.js").sendMessageSlack; +type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction; + +export function listSlackDirectoryGroupsLive( + ...args: Parameters +): ReturnType { + return listSlackDirectoryGroupsLiveImpl(...args); +} + +export function listSlackDirectoryPeersLive( + ...args: Parameters +): ReturnType { + return listSlackDirectoryPeersLiveImpl(...args); +} + +export function monitorSlackProvider( + ...args: Parameters +): ReturnType { + return monitorSlackProviderImpl(...args); +} + +export function probeSlack(...args: Parameters): ReturnType { + return probeSlackImpl(...args); +} + +export function resolveSlackChannelAllowlist( + ...args: Parameters +): ReturnType { + return resolveSlackChannelAllowlistImpl(...args); +} + +export function resolveSlackUserAllowlist( + ...args: Parameters +): ReturnType { + return resolveSlackUserAllowlistImpl(...args); +} + +export function sendMessageSlack( + ...args: Parameters +): ReturnType { + return sendMessageSlackImpl(...args); +} + +export function handleSlackAction( + ...args: Parameters +): ReturnType { + return handleSlackActionImpl(...args); +} diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index dc463625b4f..cc99abfb1c4 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,18 +1,127 @@ -export { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, +import { + auditTelegramGroupMembership as auditTelegramGroupMembershipImpl, + collectTelegramUnmentionedGroupIds as collectTelegramUnmentionedGroupIdsImpl, } from "../../../extensions/telegram/src/audit.js"; -export { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; -export { probeTelegram } from "../../../extensions/telegram/src/probe.js"; -export { - deleteMessageTelegram, - editMessageReplyMarkupTelegram, - editMessageTelegram, - pinMessageTelegram, - renameForumTopicTelegram, - sendMessageTelegram, - sendPollTelegram, - sendTypingTelegram, - unpinMessageTelegram, +import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/src/monitor.js"; +import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/src/probe.js"; +import { + deleteMessageTelegram as deleteMessageTelegramImpl, + editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, + editMessageTelegram as editMessageTelegramImpl, + pinMessageTelegram as pinMessageTelegramImpl, + renameForumTopicTelegram as renameForumTopicTelegramImpl, + sendMessageTelegram as sendMessageTelegramImpl, + sendPollTelegram as sendPollTelegramImpl, + sendTypingTelegram as sendTypingTelegramImpl, + unpinMessageTelegram as unpinMessageTelegramImpl, } from "../../../extensions/telegram/src/send.js"; -export { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; +import { resolveTelegramToken as resolveTelegramTokenImpl } from "../../../extensions/telegram/src/token.js"; + +type AuditTelegramGroupMembership = + typeof import("../../../extensions/telegram/src/audit.js").auditTelegramGroupMembership; +type CollectTelegramUnmentionedGroupIds = + typeof import("../../../extensions/telegram/src/audit.js").collectTelegramUnmentionedGroupIds; +type MonitorTelegramProvider = + typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; +type ProbeTelegram = typeof import("../../../extensions/telegram/src/probe.js").probeTelegram; +type DeleteMessageTelegram = + typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram; +type EditMessageReplyMarkupTelegram = + typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram; +type EditMessageTelegram = + typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram; +type PinMessageTelegram = + typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram; +type RenameForumTopicTelegram = + typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram; +type SendMessageTelegram = + typeof import("../../../extensions/telegram/src/send.js").sendMessageTelegram; +type SendPollTelegram = typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; +type SendTypingTelegram = + typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram; +type UnpinMessageTelegram = + typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram; +type ResolveTelegramToken = + typeof import("../../../extensions/telegram/src/token.js").resolveTelegramToken; + +export function auditTelegramGroupMembership( + ...args: Parameters +): ReturnType { + return auditTelegramGroupMembershipImpl(...args); +} + +export function collectTelegramUnmentionedGroupIds( + ...args: Parameters +): ReturnType { + return collectTelegramUnmentionedGroupIdsImpl(...args); +} + +export function monitorTelegramProvider( + ...args: Parameters +): ReturnType { + return monitorTelegramProviderImpl(...args); +} + +export function probeTelegram(...args: Parameters): ReturnType { + return probeTelegramImpl(...args); +} + +export function deleteMessageTelegram( + ...args: Parameters +): ReturnType { + return deleteMessageTelegramImpl(...args); +} + +export function editMessageReplyMarkupTelegram( + ...args: Parameters +): ReturnType { + return editMessageReplyMarkupTelegramImpl(...args); +} + +export function editMessageTelegram( + ...args: Parameters +): ReturnType { + return editMessageTelegramImpl(...args); +} + +export function pinMessageTelegram( + ...args: Parameters +): ReturnType { + return pinMessageTelegramImpl(...args); +} + +export function renameForumTopicTelegram( + ...args: Parameters +): ReturnType { + return renameForumTopicTelegramImpl(...args); +} + +export function sendMessageTelegram( + ...args: Parameters +): ReturnType { + return sendMessageTelegramImpl(...args); +} + +export function sendPollTelegram( + ...args: Parameters +): ReturnType { + return sendPollTelegramImpl(...args); +} + +export function sendTypingTelegram( + ...args: Parameters +): ReturnType { + return sendTypingTelegramImpl(...args); +} + +export function unpinMessageTelegram( + ...args: Parameters +): ReturnType { + return unpinMessageTelegramImpl(...args); +} + +export function resolveTelegramToken( + ...args: Parameters +): ReturnType { + return resolveTelegramTokenImpl(...args); +} diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 88b5d0e6138..811619b9099 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1,71 +1 @@ -import { Type } from "@sinclair/typebox"; -import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; - -export function createRuntimeWhatsAppLoginTool(): ChannelAgentTool { - return { - label: "WhatsApp Login", - name: "whatsapp_login", - ownerOnly: true, - description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", - parameters: Type.Object({ - action: Type.Unsafe<"start" | "wait">({ - type: "string", - enum: ["start", "wait"], - }), - timeoutMs: Type.Optional(Type.Number()), - force: Type.Optional(Type.Boolean()), - }), - execute: async (_toolCallId, args) => { - const { startWebLoginWithQr, waitForWebLogin } = - await import("../../../extensions/whatsapp/src/login-qr.js"); - const action = (args as { action?: string })?.action ?? "start"; - if (action === "wait") { - const result = await waitForWebLogin({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - }); - return { - content: [{ type: "text", text: result.message }], - details: { connected: result.connected }, - }; - } - - const result = await startWebLoginWithQr({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - force: - typeof (args as { force?: unknown }).force === "boolean" - ? (args as { force?: boolean }).force - : false, - }); - - if (!result.qrDataUrl) { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - details: { qr: false }, - }; - } - - const text = [ - result.message, - "", - "Open WhatsApp -> Linked Devices and scan:", - "", - `![whatsapp-qr](${result.qrDataUrl})`, - ].join("\n"); - return { - content: [{ type: "text", text }], - details: { qr: true }, - }; - }, - }; -} +export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/src/agent-tools-login.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index 584b9d8d524..4d44c7c87f6 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1 +1,7 @@ -export { loginWeb } from "../../../extensions/whatsapp/src/login.js"; +import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/src/login.js"; + +type LoginWeb = typeof import("../../../extensions/whatsapp/src/login.js").loginWeb; + +export function loginWeb(...args: Parameters): ReturnType { + return loginWebImpl(...args); +} diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index fca645e90b0..023e9e93e23 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1 +1,20 @@ -export { sendMessageWhatsApp, sendPollWhatsApp } from "../../../extensions/whatsapp/src/send.js"; +import { + sendMessageWhatsApp as sendMessageWhatsAppImpl, + sendPollWhatsApp as sendPollWhatsAppImpl, +} from "../../../extensions/whatsapp/src/send.js"; + +type SendMessageWhatsApp = + typeof import("../../../extensions/whatsapp/src/send.js").sendMessageWhatsApp; +type SendPollWhatsApp = typeof import("../../../extensions/whatsapp/src/send.js").sendPollWhatsApp; + +export function sendMessageWhatsApp( + ...args: Parameters +): ReturnType { + return sendMessageWhatsAppImpl(...args); +} + +export function sendPollWhatsApp( + ...args: Parameters +): ReturnType { + return sendPollWhatsAppImpl(...args); +} diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts index 44236ca7341..895b9665d12 100644 --- a/src/shared/operator-scope-compat.test.ts +++ b/src/shared/operator-scope-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { roleScopesAllow } from "./operator-scope-compat.js"; +import { resolveMissingRequestedScope, roleScopesAllow } from "./operator-scope-compat.js"; describe("roleScopesAllow", () => { it("allows empty requested scope lists regardless of granted scopes", () => { @@ -130,4 +130,24 @@ describe("roleScopesAllow", () => { }), ).toBe(false); }); + + it("returns the first missing requested scope with operator compatibility", () => { + expect( + resolveMissingRequestedScope({ + role: "operator", + requestedScopes: ["operator.read", "operator.write", "operator.approvals"], + allowedScopes: ["operator.write"], + }), + ).toBe("operator.approvals"); + }); + + it("returns null when all requested scopes are satisfied", () => { + expect( + resolveMissingRequestedScope({ + role: "node", + requestedScopes: ["system.run"], + allowedScopes: ["system.run", "operator.admin"], + }), + ).toBeNull(); + }); }); diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts index 4b1d954b70f..cf184558caa 100644 --- a/src/shared/operator-scope-compat.ts +++ b/src/shared/operator-scope-compat.ts @@ -47,3 +47,22 @@ export function roleScopesAllow(params: { } return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet)); } + +export function resolveMissingRequestedScope(params: { + role: string; + requestedScopes: readonly string[]; + allowedScopes: readonly string[]; +}): string | null { + for (const scope of params.requestedScopes) { + if ( + !roleScopesAllow({ + role: params.role, + requestedScopes: [scope], + allowedScopes: params.allowedScopes, + }) + ) { + return scope; + } + } + return null; +} diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 39793fd2ba4..7d48dfb8e07 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -572,6 +572,29 @@ function buildTtsFailureResult(errors: string[]): { success: false; error: strin }; } +function resolveReadySpeechProvider(params: { + provider: TtsProvider; + cfg: OpenClawConfig; + config: ResolvedTtsConfig; + errors: string[]; + requireTelephony?: boolean; +}): NonNullable> | null { + const resolvedProvider = getSpeechProvider(params.provider, params.cfg); + if (!resolvedProvider) { + params.errors.push(`${params.provider}: no provider registered`); + return null; + } + if (!resolvedProvider.isConfigured({ cfg: params.cfg, config: params.config })) { + params.errors.push(`${params.provider}: not configured`); + return null; + } + if (params.requireTelephony && !resolvedProvider.synthesizeTelephony) { + params.errors.push(`${params.provider}: unsupported for telephony`); + return null; + } + return resolvedProvider; +} + function resolveTtsRequestSetup(params: { text: string; cfg: OpenClawConfig; @@ -627,13 +650,13 @@ export async function textToSpeech(params: { for (const provider of providers) { const providerStart = Date.now(); try { - const resolvedProvider = getSpeechProvider(provider, params.cfg); + const resolvedProvider = resolveReadySpeechProvider({ + provider, + cfg: params.cfg, + config, + errors, + }); if (!resolvedProvider) { - errors.push(`${provider}: no provider registered`); - continue; - } - if (!resolvedProvider.isConfigured({ cfg: params.cfg, config })) { - errors.push(`${provider}: not configured`); continue; } const synthesis = await resolvedProvider.synthesize({ @@ -689,17 +712,14 @@ export async function textToSpeechTelephony(params: { for (const provider of providers) { const providerStart = Date.now(); try { - const resolvedProvider = getSpeechProvider(provider, params.cfg); - if (!resolvedProvider) { - errors.push(`${provider}: no provider registered`); - continue; - } - if (!resolvedProvider.isConfigured({ cfg: params.cfg, config })) { - errors.push(`${provider}: not configured`); - continue; - } - if (!resolvedProvider.synthesizeTelephony) { - errors.push(`${provider}: unsupported for telephony`); + const resolvedProvider = resolveReadySpeechProvider({ + provider, + cfg: params.cfg, + config, + errors, + requireTelephony: true, + }); + if (!resolvedProvider?.synthesizeTelephony) { continue; } const synthesis = await resolvedProvider.synthesizeTelephony({